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.
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/PKG-INFO +5 -4
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/README.md +3 -2
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/pyproject.toml +4 -4
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/cli.py +11 -1
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/commands/config.py +1 -1
- exosphere_cli-2.2.0/src/exosphere/commands/connections.py +204 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/commands/host.py +18 -5
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/commands/inventory.py +9 -1
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/commands/report.py +2 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/commands/sudo.py +9 -4
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/commands/utils.py +22 -1
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/config.py +52 -38
- exosphere_cli-2.2.0/src/exosphere/context.py +18 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/data.py +40 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/database.py +6 -5
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/inventory.py +37 -40
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/main.py +34 -0
- exosphere_cli-2.2.0/src/exosphere/migrations.py +49 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/objects.py +216 -138
- exosphere_cli-2.2.0/src/exosphere/pipelining.py +182 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/providers/debian.py +4 -6
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/providers/freebsd.py +5 -9
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/providers/openbsd.py +9 -8
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/providers/redhat.py +21 -26
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/repl.py +170 -66
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/reporting.py +1 -1
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/setup/detect.py +15 -23
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/ui/dashboard.py +19 -93
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/ui/elements.py +98 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/ui/inventory.py +17 -65
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/ui/logs.py +37 -12
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/ui/messages.py +10 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/ui/style.tcss +0 -2
- exosphere_cli-2.1.2/src/exosphere/context.py +0 -11
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/LICENSE +0 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/__init__.py +0 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/commands/__init__.py +0 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/commands/ui.py +0 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/commands/version.py +0 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/errors.py +0 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/fspaths.py +0 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/providers/__init__.py +0 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/providers/api.py +0 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/providers/factory.py +0 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/schema/__init__.py +0 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/schema/host-report.schema.json +0 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/security.py +0 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/setup/__init__.py +0 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/templates/report.html.j2 +0 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/templates/report.md.j2 +0 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/templates/report.txt.j2 +0 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/ui/__init__.py +0 -0
- {exosphere_cli-2.1.2 → exosphere_cli-2.2.0}/src/exosphere/ui/app.py +0 -0
- {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.
|
|
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.
|
|
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://
|
|
44
|
-
<a href="https://
|
|
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://
|
|
7
|
-
<a href="https://
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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")
|
|
@@ -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
|
|
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[
|
|
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[
|
|
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[
|
|
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[
|
|
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:
|
|
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"
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
#
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|