exosphere-cli 2.1.2__tar.gz → 2.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.
Files changed (54) hide show
  1. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/PKG-INFO +5 -4
  2. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/README.md +3 -2
  3. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/pyproject.toml +4 -4
  4. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/cli.py +11 -1
  5. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/commands/config.py +1 -1
  6. exosphere_cli-2.2.0/src/exosphere/commands/connections.py +204 -0
  7. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/commands/host.py +18 -5
  8. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/commands/inventory.py +9 -1
  9. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/commands/report.py +2 -0
  10. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/commands/sudo.py +9 -4
  11. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/commands/utils.py +22 -1
  12. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/config.py +52 -38
  13. exosphere_cli-2.2.0/src/exosphere/context.py +18 -0
  14. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/data.py +40 -0
  15. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/database.py +6 -5
  16. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/inventory.py +37 -40
  17. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/main.py +34 -0
  18. exosphere_cli-2.2.0/src/exosphere/migrations.py +49 -0
  19. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/objects.py +216 -138
  20. exosphere_cli-2.2.0/src/exosphere/pipelining.py +182 -0
  21. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/providers/debian.py +4 -6
  22. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/providers/freebsd.py +5 -9
  23. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/providers/openbsd.py +9 -8
  24. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/providers/redhat.py +21 -26
  25. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/repl.py +170 -66
  26. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/reporting.py +1 -1
  27. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/setup/detect.py +15 -23
  28. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/ui/dashboard.py +19 -93
  29. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/ui/elements.py +98 -0
  30. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/ui/inventory.py +17 -65
  31. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/ui/logs.py +37 -12
  32. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/ui/messages.py +10 -0
  33. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/ui/style.tcss +0 -2
  34. exosphere_cli-2.1.2/src/exosphere/context.py +0 -11
  35. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/LICENSE +0 -0
  36. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/__init__.py +0 -0
  37. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/commands/__init__.py +0 -0
  38. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/commands/ui.py +0 -0
  39. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/commands/version.py +0 -0
  40. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/errors.py +0 -0
  41. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/fspaths.py +0 -0
  42. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/providers/__init__.py +0 -0
  43. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/providers/api.py +0 -0
  44. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/providers/factory.py +0 -0
  45. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/schema/__init__.py +0 -0
  46. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/schema/host-report.schema.json +0 -0
  47. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/security.py +0 -0
  48. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/setup/__init__.py +0 -0
  49. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/templates/report.html.j2 +0 -0
  50. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/templates/report.md.j2 +0 -0
  51. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/templates/report.txt.j2 +0 -0
  52. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/ui/__init__.py +0 -0
  53. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/ui/app.py +0 -0
  54. {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/ui/context.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: exosphere-cli
3
- Version: 2.1.2
3
+ Version: 2.2.0
4
4
  Summary: CLI/TUI driven patch reporting for remote Unix-like systems.
5
5
  Author: Alexandre Gauthier
6
6
  Author-email: Alexandre Gauthier <alex@underwares.org>
@@ -20,7 +20,7 @@ Classifier: Topic :: Utilities
20
20
  Classifier: Typing :: Typed
21
21
  Requires-Dist: fabric>=3.2.2
22
22
  Requires-Dist: typer>=0.20.0
23
- Requires-Dist: textual>=6.2.0
23
+ Requires-Dist: textual>=6.7.0
24
24
  Requires-Dist: pyyaml>=6.0.3
25
25
  Requires-Dist: platformdirs>=4.3.8
26
26
  Requires-Dist: prompt-toolkit>=3.0.51
@@ -40,8 +40,9 @@ Description-Content-Type: text/markdown
40
40
  <p>
41
41
  <a href="https://github.com/mrdaemon/exosphere/releases"><img src="https://img.shields.io/github/v/release/mrdaemon/exosphere" alt="GitHub release"></a>
42
42
  <a href="https://pypi.org/project/exosphere-cli/"><img src="https://img.shields.io/pypi/v/exosphere-cli" alt="PyPI"></a>
43
- <a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.13+-purple.svg" alt="Python Version"></a>
44
- <a href="https://github.com/mrdaemon/exosphere/actions/workflows/test-suite.yml"><img src="https://img.shields.io/github/actions/workflow/status/mrdaemon/exosphere/test-suite.yml" alt="Test Suite"></a>
43
+ <a href="https://github.com/mrdaemon/exosphere/tree/main"><img src="https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmrdaemon%2Fexosphere%2Frefs%2Fheads%2Fmain%2Fpyproject.toml&query=%24.project.version&label=dev&color=purple" alt="Current Dev Version"></a>
44
+ <a href="https://www.python.org/"><img src="https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Fmrdaemon%2Fexosphere%2Frefs%2Fheads%2Fmain%2Fpyproject.toml" alt="Python Version"></a>
45
+ <a href="https://github.com/mrdaemon/exosphere/actions/workflows/test-suite.yml"><img src="https://img.shields.io/github/actions/workflow/status/mrdaemon/exosphere/test-suite.yml?label=test%20suite" alt="Test Suite"></a>
45
46
  <a href="https://github.com/mrdaemon/exosphere/blob/main/LICENSE"><img src="https://img.shields.io/github/license/mrdaemon/exosphere" alt="License"></a>
46
47
  </p>
47
48
 
@@ -3,8 +3,9 @@
3
3
  <p>
4
4
  <a href="https://github.com/mrdaemon/exosphere/releases"><img src="https://img.shields.io/github/v/release/mrdaemon/exosphere" alt="GitHub release"></a>
5
5
  <a href="https://pypi.org/project/exosphere-cli/"><img src="https://img.shields.io/pypi/v/exosphere-cli" alt="PyPI"></a>
6
- <a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.13+-purple.svg" alt="Python Version"></a>
7
- <a href="https://github.com/mrdaemon/exosphere/actions/workflows/test-suite.yml"><img src="https://img.shields.io/github/actions/workflow/status/mrdaemon/exosphere/test-suite.yml" alt="Test Suite"></a>
6
+ <a href="https://github.com/mrdaemon/exosphere/tree/main"><img src="https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmrdaemon%2Fexosphere%2Frefs%2Fheads%2Fmain%2Fpyproject.toml&query=%24.project.version&label=dev&color=purple" alt="Current Dev Version"></a>
7
+ <a href="https://www.python.org/"><img src="https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Fmrdaemon%2Fexosphere%2Frefs%2Fheads%2Fmain%2Fpyproject.toml" alt="Python Version"></a>
8
+ <a href="https://github.com/mrdaemon/exosphere/actions/workflows/test-suite.yml"><img src="https://img.shields.io/github/actions/workflow/status/mrdaemon/exosphere/test-suite.yml?label=test%20suite" alt="Test Suite"></a>
8
9
  <a href="https://github.com/mrdaemon/exosphere/blob/main/LICENSE"><img src="https://img.shields.io/github/license/mrdaemon/exosphere" alt="License"></a>
9
10
  </p>
10
11
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "exosphere-cli"
3
- version = "2.1.2"
3
+ version = "2.2.0"
4
4
  description = "CLI/TUI driven patch reporting for remote Unix-like systems."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -29,7 +29,7 @@ license-files = [
29
29
  dependencies = [
30
30
  "fabric>=3.2.2",
31
31
  "typer>=0.20.0",
32
- "textual>=6.2.0",
32
+ "textual>=6.7.0",
33
33
  "pyyaml>=6.0.3",
34
34
  "platformdirs>=4.3.8",
35
35
  "prompt-toolkit>=3.0.51",
@@ -51,7 +51,7 @@ dev = [
51
51
  "pytest-mock>=3.14.1",
52
52
  "renku-sphinx-theme>=0.5.0",
53
53
  "ruff>=0.11.5",
54
- "sphinx>=8.2.3",
54
+ "sphinx>=8.2.3,<9.0",
55
55
  "sphinx-autobuild>=2024.10.3",
56
56
  "sphinx-lint>=1.0.0",
57
57
  "sphinx-tabs>=3.4.7",
@@ -74,7 +74,7 @@ issues = "https://github.com/mrdaemon/exosphere/issues"
74
74
  exosphere = "exosphere.main:main"
75
75
 
76
76
  [build-system]
77
- requires = ["uv_build>=0.7.19,<0.8.0"]
77
+ requires = ["uv_build>=0.7.19,<0.10.0"]
78
78
  build-backend = "uv_build"
79
79
 
80
80
  [tool.uv.build-backend]
@@ -15,7 +15,16 @@ from rich.console import Console
15
15
  from typer import Context, Exit, Option, Typer
16
16
 
17
17
  from exosphere import __version__, app_config
18
- from exosphere.commands import config, host, inventory, report, sudo, ui, version
18
+ from exosphere.commands import (
19
+ config,
20
+ connections,
21
+ host,
22
+ inventory,
23
+ report,
24
+ sudo,
25
+ ui,
26
+ version,
27
+ )
19
28
  from exosphere.commands.utils import print_version
20
29
  from exosphere.repl import start_repl
21
30
 
@@ -36,6 +45,7 @@ app = Typer(
36
45
  # Setup commands from modules
37
46
  app.add_typer(inventory.app, name="inventory")
38
47
  app.add_typer(host.app, name="host")
48
+ app.add_typer(connections.app, name="connections", hidden=True) # Interactive-only
39
49
  app.add_typer(ui.app, name="ui")
40
50
  app.add_typer(config.app, name="config")
41
51
  app.add_typer(report.app, name="report")
@@ -150,7 +150,7 @@ def diff(
150
150
  help="Show full configuration diff, including unmodified options.",
151
151
  ),
152
152
  ] = False,
153
- ):
153
+ ) -> None:
154
154
  """
155
155
  Show the differences between the current configuration and the defaults.
156
156
 
@@ -0,0 +1,204 @@
1
+ """
2
+ Connections command module
3
+ """
4
+
5
+ import time
6
+
7
+ import typer
8
+ from rich.table import Table
9
+ from typing_extensions import Annotated
10
+
11
+ from exosphere import app_config
12
+ from exosphere.commands.utils import (
13
+ HostArgument,
14
+ console,
15
+ err_console,
16
+ get_hosts_or_error,
17
+ )
18
+
19
+ ROOT_HELP = """
20
+ Connection State Management Commands
21
+
22
+ Commands to inspect the state of SSH connections to inventory hosts.
23
+ These commands are only useful when SSH Pipelining is enabled,
24
+ otherwise no persistent connections to hosts are maintained.
25
+
26
+ Only useful from Interactive Mode, as connections are not maintained
27
+ between separate CLI invocations.
28
+ """
29
+
30
+ app = typer.Typer(
31
+ help=ROOT_HELP,
32
+ no_args_is_help=True,
33
+ )
34
+
35
+
36
+ def _format_duration(seconds: int) -> str:
37
+ """Format duration in seconds to human-readable string."""
38
+ if seconds < 60:
39
+ return f"{seconds}s"
40
+ elif seconds < 3600:
41
+ minutes = seconds // 60
42
+ remaining_seconds = seconds % 60
43
+ return (
44
+ f"{minutes}m {remaining_seconds}s" if remaining_seconds else f"{minutes}m"
45
+ )
46
+ else:
47
+ hours = seconds // 3600
48
+ remaining_minutes = (seconds % 3600) // 60
49
+ return f"{hours}h {remaining_minutes}m" if remaining_minutes else f"{hours}h"
50
+
51
+
52
+ @app.command()
53
+ def show(
54
+ names: Annotated[
55
+ list[str] | None,
56
+ typer.Argument(
57
+ help="Hosts to show connection state for. If omitted, shows all hosts.",
58
+ metavar="[HOSTS]...",
59
+ ),
60
+ HostArgument(multiple=True),
61
+ ] = None,
62
+ active_only: Annotated[
63
+ bool,
64
+ typer.Option(
65
+ "--active",
66
+ "-a",
67
+ help="Show only hosts with active connections.",
68
+ ),
69
+ ] = False,
70
+ ) -> None:
71
+ """
72
+ Show SSH connection state for inventory hosts.
73
+
74
+ Display the current SSH connection state for specified hosts, or
75
+ all hosts if none are specified.
76
+
77
+ Connections that have been idle for longer than the configured
78
+ maximum age will be marked as "Expiring".
79
+
80
+ Only useful when SSH Pipelining is enabled.
81
+ """
82
+
83
+ if not app_config["options"]["ssh_pipelining"]:
84
+ err_console.print("[yellow]SSH Pipelining is currently disabled.[/yellow]")
85
+ err_console.print("No persistent connections to hosts are maintained.")
86
+ raise typer.Exit(1)
87
+
88
+ pipelining_max_age = app_config["options"]["ssh_pipelining_lifetime"]
89
+ pipelining_interval = app_config["options"]["ssh_pipelining_reap_interval"]
90
+
91
+ hosts = get_hosts_or_error(names)
92
+ if hosts is None:
93
+ raise typer.Exit(code=2) # Argument error
94
+
95
+ # Filter for active connections if requested
96
+ if active_only:
97
+ hosts = [h for h in hosts if h.is_connected]
98
+ if not hosts:
99
+ console.print("No active connections.")
100
+ raise typer.Exit(code=0)
101
+
102
+ table_title = "SSH Connections"
103
+
104
+ if active_only:
105
+ table_title += " (Active Only)"
106
+
107
+ table = Table(
108
+ "Host",
109
+ "IP",
110
+ "Port",
111
+ "Idle",
112
+ "State",
113
+ title=table_title,
114
+ caption=f"Idle connections older than {pipelining_max_age}s are closed every {pipelining_interval}s",
115
+ caption_justify="right",
116
+ )
117
+
118
+ for host in hosts:
119
+ host_name = host.name
120
+ host_ip = host.ip
121
+ host_port = host.port
122
+
123
+ if host.is_connected and host.connection_last_used is not None:
124
+ idle_seconds = round(time.time() - host.connection_last_used)
125
+ host_idle = _format_duration(idle_seconds)
126
+
127
+ # Expiring connections should be marked as such
128
+ # The reaper adds some splay time.
129
+ if idle_seconds >= pipelining_max_age:
130
+ state = "[yellow]Expiring[/yellow]"
131
+ else:
132
+ state = "[green]Connected[/green]"
133
+ else:
134
+ host_idle = "[dim]—[/dim]"
135
+ idle_seconds = None
136
+ state = "[dim]Inactive[/dim]"
137
+
138
+ table.add_row(
139
+ str(host_name),
140
+ str(host_ip),
141
+ str(host_port),
142
+ host_idle,
143
+ state,
144
+ )
145
+
146
+ console.print(table)
147
+
148
+
149
+ @app.command()
150
+ def close(
151
+ names: Annotated[
152
+ list[str] | None,
153
+ typer.Argument(
154
+ help="Hosts to close connections for. If omitted, close all connections.",
155
+ metavar="[HOSTS]...",
156
+ ),
157
+ HostArgument(multiple=True),
158
+ ] = None,
159
+ verbose: Annotated[
160
+ bool,
161
+ typer.Option(
162
+ "--verbose",
163
+ "-v",
164
+ help="Show detailed output of closed connections.",
165
+ ),
166
+ ] = False,
167
+ ) -> None:
168
+ """
169
+ Close SSH connections explicitly
170
+
171
+ Close SSH connections to specified hosts, or all hosts if none are specified.
172
+ Only useful when SSH Pipelining is enabled.
173
+ """
174
+
175
+ if not app_config["options"]["ssh_pipelining"]:
176
+ err_console.print("[yellow]SSH Pipelining is currently disabled.[/yellow]")
177
+ err_console.print("No persistent connections to hosts are maintained.")
178
+ raise typer.Exit(1)
179
+
180
+ hosts = get_hosts_or_error(names)
181
+ if hosts is None:
182
+ raise typer.Exit(code=2) # Argument error
183
+
184
+ closed_count = 0
185
+ inactive_count = 0
186
+
187
+ for host in hosts:
188
+ if not host.is_connected:
189
+ inactive_count += 1
190
+ continue
191
+
192
+ host.close()
193
+ closed_count += 1
194
+
195
+ if verbose:
196
+ console.print(f" [bold]{host.name}[/bold]: Connection closed.")
197
+
198
+ if closed_count > 0:
199
+ console.print(f"Closed [bold]{closed_count}[/bold] active connection(s).")
200
+
201
+ if verbose and inactive_count > 0:
202
+ console.print(
203
+ f"[dim]Skipped {inactive_count} host(s) with no active connections.[/dim]"
204
+ )
@@ -15,7 +15,12 @@ from rich.text import Text
15
15
  from typing_extensions import Annotated
16
16
 
17
17
  from exosphere import app_config
18
- from exosphere.commands.utils import console, err_console, get_host_or_error
18
+ from exosphere.commands.utils import (
19
+ HostArgument,
20
+ console,
21
+ err_console,
22
+ get_host_or_error,
23
+ )
19
24
  from exosphere.objects import Host
20
25
 
21
26
  # Reuse the save function from the inventory command
@@ -143,7 +148,9 @@ def _display_updates_table(host: Host, security_only: bool) -> None:
143
148
 
144
149
  @app.command()
145
150
  def show(
146
- name: Annotated[str, typer.Argument(help="Host from inventory to show")],
151
+ name: Annotated[
152
+ str, typer.Argument(help="Host from inventory to show"), HostArgument()
153
+ ],
147
154
  include_updates: Annotated[
148
155
  bool,
149
156
  typer.Option(
@@ -204,7 +211,9 @@ def show(
204
211
 
205
212
  @app.command()
206
213
  def discover(
207
- name: Annotated[str, typer.Argument(help="Host from inventory to discover")],
214
+ name: Annotated[
215
+ str, typer.Argument(help="Host from inventory to discover"), HostArgument()
216
+ ],
208
217
  ) -> None:
209
218
  """
210
219
  Gather platform data for host.
@@ -239,7 +248,9 @@ def discover(
239
248
 
240
249
  @app.command()
241
250
  def refresh(
242
- name: Annotated[str, typer.Argument(help="Host from inventory to refresh")],
251
+ name: Annotated[
252
+ str, typer.Argument(help="Host from inventory to refresh"), HostArgument()
253
+ ],
243
254
  full: Annotated[
244
255
  bool, typer.Option("--sync", "-s", help="Also sync package repositories")
245
256
  ] = False,
@@ -320,7 +331,9 @@ def refresh(
320
331
 
321
332
  @app.command()
322
333
  def ping(
323
- name: Annotated[str, typer.Argument(help="Host from inventory to ping")],
334
+ name: Annotated[
335
+ str, typer.Argument(help="Host from inventory to ping"), HostArgument()
336
+ ],
324
337
  ) -> None:
325
338
  """
326
339
  Ping a specific host to check its reachability.
@@ -19,6 +19,7 @@ from typing_extensions import Annotated
19
19
 
20
20
  from exosphere import app_config
21
21
  from exosphere.commands.utils import (
22
+ HostArgument,
22
23
  console,
23
24
  err_console,
24
25
  get_hosts_or_error,
@@ -60,6 +61,7 @@ def discover(
60
61
  typer.Argument(
61
62
  help="Host(s) to discover, all if not specified", metavar="[HOST]..."
62
63
  ),
64
+ HostArgument(multiple=True),
63
65
  ] = None,
64
66
  ) -> None:
65
67
  """
@@ -136,6 +138,7 @@ def refresh(
136
138
  typer.Argument(
137
139
  help="Host(s) to refresh, all if not specified", metavar="[HOST]..."
138
140
  ),
141
+ HostArgument(multiple=True),
139
142
  ] = None,
140
143
  ) -> None:
141
144
  """
@@ -243,6 +246,7 @@ def ping(
243
246
  typer.Argument(
244
247
  help="Host(s) to ping, all if not specified", metavar="[HOST]..."
245
248
  ),
249
+ HostArgument(multiple=True),
246
250
  ] = None,
247
251
  ) -> None:
248
252
  """
@@ -322,6 +326,7 @@ def status(
322
326
  typer.Argument(
323
327
  help="Host(s) to show status for, all if not specified", metavar="[HOST]..."
324
328
  ),
329
+ HostArgument(multiple=True),
325
330
  ] = None,
326
331
  ) -> None:
327
332
  """
@@ -423,7 +428,7 @@ def status(
423
428
  console.print(table)
424
429
 
425
430
 
426
- @app.command()
431
+ @app.command(hidden=True) # Interactive-only command
427
432
  def save() -> None:
428
433
  """
429
434
  Save the current inventory state to disk
@@ -439,6 +444,9 @@ def save() -> None:
439
444
  Since this is enabled by default, you will rarely need to invoke this
440
445
  manually.
441
446
 
447
+ This command is only available in interactive mode, as the inventory
448
+ state is not persisted between separate CLI invocations when autosave
449
+ is disabled.
442
450
  """
443
451
  logger = logging.getLogger(__name__)
444
452
  logger.debug("Starting inventory save operation")
@@ -9,6 +9,7 @@ from rich.json import JSON
9
9
  from typing_extensions import Annotated
10
10
 
11
11
  from exosphere.commands.utils import (
12
+ HostArgument,
12
13
  console,
13
14
  err_console,
14
15
  get_hosts_or_error,
@@ -92,6 +93,7 @@ def generate(
92
93
  help="One or more hosts to include (all if not specified)",
93
94
  metavar="[HOST]...",
94
95
  ),
96
+ HostArgument(multiple=True),
95
97
  ] = None,
96
98
  ) -> None:
97
99
  """
@@ -10,7 +10,9 @@ from rich.table import Table
10
10
  from typing_extensions import Annotated
11
11
 
12
12
  from exosphere import app_config, context
13
+ from exosphere.commands.utils import HostArgument, HostOption
13
14
  from exosphere.data import ProviderInfo
15
+ from exosphere.inventory import Inventory
14
16
  from exosphere.objects import Host
15
17
  from exosphere.providers.factory import PkgManagerFactory
16
18
  from exosphere.security import SudoPolicy, check_sudo_policy, has_sudo_flag
@@ -31,7 +33,7 @@ console = Console()
31
33
  err_console = Console(stderr=True)
32
34
 
33
35
 
34
- def _get_inventory():
36
+ def _get_inventory() -> Inventory:
35
37
  """
36
38
  Get the inventory from context
37
39
  A convenience wrapper that bails if the inventory is not initialized
@@ -146,7 +148,7 @@ def _get_username(user: str | None, host: Host | None = None) -> str:
146
148
 
147
149
 
148
150
  @app.command()
149
- def policy():
151
+ def policy() -> None:
150
152
  """
151
153
  Show the current global Sudo Policy.
152
154
 
@@ -158,8 +160,10 @@ def policy():
158
160
 
159
161
  @app.command()
160
162
  def check(
161
- host: str = typer.Argument(help="Host to check security policies for"),
162
- ):
163
+ host: Annotated[
164
+ str, typer.Argument(help="Host to check security policies for"), HostArgument()
165
+ ],
166
+ ) -> None:
163
167
  """
164
168
  Check the effective Sudo Policies for a given host.
165
169
 
@@ -310,6 +314,7 @@ def generate(
310
314
  rich_help_panel="Mandatory Options (mutually exclusive)",
311
315
  show_default=False,
312
316
  ),
317
+ HostOption(),
313
318
  ] = None,
314
319
  provider: Annotated[
315
320
  str | None,
@@ -10,6 +10,7 @@ as well as display bits around task execution, errors and status.
10
10
 
11
11
  import platform
12
12
  import sys
13
+ from dataclasses import dataclass
13
14
 
14
15
  import typer
15
16
  from rich import box
@@ -33,6 +34,26 @@ console = Console()
33
34
  err_console = Console(stderr=True)
34
35
 
35
36
 
37
+ @dataclass
38
+ class HostArgument:
39
+ """
40
+ Annotation class for typer Host arguments for REPL completion.
41
+
42
+ Set multiple=True to allow completion of multiple hosts.
43
+ """
44
+
45
+ multiple: bool = False
46
+
47
+
48
+ @dataclass
49
+ class HostOption:
50
+ """
51
+ Annotation class for typer Host options for REPL completion.
52
+ """
53
+
54
+ pass
55
+
56
+
36
57
  def print_version() -> None:
37
58
  """
38
59
  Print the current version of Exosphere to stdout.
@@ -182,7 +203,7 @@ def get_hosts_or_error(
182
203
  if unmatched:
183
204
  err_console.print(
184
205
  Panel.fit(
185
- f"Hosts not found in inventory: {', '.join(unmatched)}",
206
+ f"Host(s) not found in inventory: {', '.join(unmatched)}",
186
207
  title="Error",
187
208
  )
188
209
  )
@@ -55,6 +55,9 @@ class Configuration(dict):
55
55
  "default_username": None, # Default global username to use for SSH
56
56
  "default_sudo_policy": "skip", # Global sudo policy for package manager ops
57
57
  "max_threads": 15, # Maximum number of threads to use for parallel ops
58
+ "ssh_pipelining": False, # Enable SSH pipelining for SSH connections
59
+ "ssh_pipelining_lifetime": 300, # Max lifetime (secs) of SSH connections
60
+ "ssh_pipelining_reap_interval": 30, # Interval (secs) between reaper checks
58
61
  "update_checks": True, # Set to false if you want to disable PyPI checks
59
62
  "no_banner": False, # Disable the REPL banner on startup
60
63
  },
@@ -272,47 +275,58 @@ class Configuration(dict):
272
275
  # in the configuration.
273
276
  hosts = self.get("hosts", [])
274
277
  if isinstance(hosts, list):
275
- # uniqueness constraint for host names
276
- names: list[str] = [
277
- str(host.get("name"))
278
- for host in hosts
279
- if isinstance(host, dict) and "name" in host
280
- ]
281
- dupes: set[str] = {str(name) for name in names if names.count(name) > 1}
282
- if dupes:
283
- msg = f"Duplicate host names found in configuration: {', '.join(dupes)}"
278
+ self._validate_hosts(hosts)
279
+
280
+ return True
281
+
282
+ def _validate_hosts(self, hosts: list) -> None:
283
+ """
284
+ Validate the hosts list configuration.
285
+
286
+ :param hosts: List of host configuration dictionaries
287
+ :raises ValueError: On validation failure
288
+ """
289
+ # Check for duplicate host names
290
+ names: list[str] = [
291
+ str(host.get("name"))
292
+ for host in hosts
293
+ if isinstance(host, dict) and "name" in host
294
+ ]
295
+
296
+ dupes: set[str] = {str(name) for name in names if names.count(name) > 1}
297
+
298
+ if dupes:
299
+ msg = f"Duplicate host names found in configuration: {', '.join(dupes)}"
300
+ raise ValueError(msg)
301
+
302
+ # Validate each host entry
303
+ for host in hosts:
304
+ if not isinstance(host, dict):
305
+ continue
306
+
307
+ # Name field MUST be present
308
+ if "name" not in host:
309
+ msg = "Host entry is missing required 'name' field"
284
310
  raise ValueError(msg)
285
311
 
286
- # Validation for individual entries
287
- for host in hosts:
288
- if not isinstance(host, dict):
289
- continue
290
-
291
- # Name field MUST be present
292
- if "name" not in host:
293
- msg = "Host entry is missing required 'name' field"
294
- raise ValueError(msg)
295
-
296
- # IP field MUST be present
297
- if "ip" not in host:
298
- host_name = host.get("name", "unnamed host")
299
- msg = f"Host '{host_name}' is missing required 'ip' field"
300
- raise ValueError(msg)
301
-
302
- # IP field cannot contain '@' character
303
- # Library will interpret this as a username which will result
304
- # in a lot of undefined or unexpected behaviors.
305
- # We allow it in the username field, however, for kerberos reasons.
306
- if "ip" in host and "@" in str(host["ip"]):
307
- host_name = host.get("name", "unnamed host")
308
- msg = (
309
- f"Host '{host_name}' has invalid hostname or ip: "
310
- "'@' character is not allowed. "
311
- "If you are trying to specify a username, use the 'username' option."
312
- )
313
- raise ValueError(msg)
312
+ # IP field MUST be present
313
+ if "ip" not in host:
314
+ host_name = host.get("name", "unnamed host")
315
+ msg = f"Host '{host_name}' is missing required 'ip' field"
316
+ raise ValueError(msg)
314
317
 
315
- return True
318
+ # IP field cannot contain '@' character
319
+ # Library will interpret this as a username which will result
320
+ # in a lot of undefined or unexpected behaviors.
321
+ # We allow it in the username field, however, for kerberos reasons.
322
+ if "ip" in host and "@" in str(host["ip"]):
323
+ host_name = host.get("name", "(unknown)")
324
+ msg = (
325
+ f"Host '{host_name}' has invalid hostname or ip: "
326
+ "'@' character is not allowed. "
327
+ "If you are trying to specify a username, use the 'username' option."
328
+ )
329
+ raise ValueError(msg)
316
330
 
317
331
  def deep_update(self, d: dict, u: dict) -> dict:
318
332
  """
@@ -0,0 +1,18 @@
1
+ """
2
+ Context module for Exosphere
3
+
4
+ Global context, variables and other shared state objects used
5
+ throughout the Exosphere application.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from exosphere.inventory import Inventory
14
+ from exosphere.pipelining import ConnectionReaper
15
+
16
+ inventory: Inventory | None = None
17
+ reaper: ConnectionReaper | None = None
18
+ confpath: str | None = None