exosphere-cli 2.4.0__tar.gz → 2.4.2__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 (53) hide show
  1. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/PKG-INFO +91 -7
  2. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/README.md +90 -5
  3. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/pyproject.toml +5 -6
  4. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/errors.py +12 -0
  5. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/providers/api.py +2 -9
  6. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/providers/debian.py +33 -9
  7. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/providers/freebsd.py +14 -10
  8. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/providers/openbsd.py +8 -3
  9. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/providers/redhat.py +79 -162
  10. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/setup/detect.py +26 -29
  11. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/LICENSE +0 -0
  12. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/__init__.py +0 -0
  13. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/cli.py +0 -0
  14. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/commands/__init__.py +0 -0
  15. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/commands/config.py +0 -0
  16. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/commands/connections.py +0 -0
  17. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/commands/host.py +0 -0
  18. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/commands/inventory.py +0 -0
  19. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/commands/report.py +0 -0
  20. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/commands/sudo.py +0 -0
  21. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/commands/ui.py +0 -0
  22. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/commands/utils.py +0 -0
  23. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/commands/version.py +0 -0
  24. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/config.py +0 -0
  25. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/context.py +0 -0
  26. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/data.py +0 -0
  27. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/database.py +0 -0
  28. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/fspaths.py +0 -0
  29. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/inventory.py +0 -0
  30. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/main.py +0 -0
  31. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/migrations.py +0 -0
  32. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/objects.py +0 -0
  33. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/pipelining.py +0 -0
  34. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/providers/__init__.py +0 -0
  35. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/providers/factory.py +0 -0
  36. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/repl.py +0 -0
  37. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/reporting.py +0 -0
  38. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/schema/__init__.py +0 -0
  39. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/schema/host-report.schema.json +0 -0
  40. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/security.py +0 -0
  41. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/setup/__init__.py +0 -0
  42. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/templates/report.html.j2 +0 -0
  43. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/templates/report.md.j2 +0 -0
  44. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/templates/report.txt.j2 +0 -0
  45. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/ui/__init__.py +0 -0
  46. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/ui/app.py +0 -0
  47. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/ui/context.py +0 -0
  48. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/ui/dashboard.py +0 -0
  49. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/ui/elements.py +0 -0
  50. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/ui/inventory.py +0 -0
  51. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/ui/logs.py +0 -0
  52. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/ui/messages.py +0 -0
  53. {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/ui/style.tcss +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: exosphere-cli
3
- Version: 2.4.0
3
+ Version: 2.4.2
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>
@@ -9,7 +9,6 @@ License-File: LICENSE
9
9
  Classifier: Development Status :: 5 - Production/Stable
10
10
  Classifier: Environment :: Console
11
11
  Classifier: Intended Audience :: System Administrators
12
- Classifier: License :: OSI Approved :: MIT License
13
12
  Classifier: Natural Language :: English
14
13
  Classifier: Operating System :: OS Independent
15
14
  Classifier: Programming Language :: Python :: 3.13
@@ -87,7 +86,7 @@ Supported platforms for remote hosts include:
87
86
  - FreeBSD (using pkg)
88
87
  - OpenBSD (using pkg_add)
89
88
 
90
- Unsupported platform with with SSH connectivity checks only:
89
+ Unsupported platforms with SSH connectivity checks only:
91
90
 
92
91
  - Other Linux distributions (e.g., Arch Linux, Gentoo, NixOS, etc.)
93
92
  - Other BSD systems (NetBSD)
@@ -101,9 +100,11 @@ This includes network equipment with proprietary operating systems, etc.
101
100
  For installation instructions, configuration and usage examples,
102
101
  [full documentation](https://exosphere.readthedocs.io/) is available.
103
102
 
104
- ## Development Quick Start
103
+ ## Development
105
104
 
106
- tl;dr, use [uv](https://docs.astral.sh/uv/getting-started/installation/)
105
+ ### Development Quick Start
106
+
107
+ TL;DR, use [uv](https://docs.astral.sh/uv/getting-started/installation/)
107
108
 
108
109
  ```bash
109
110
  uv sync --dev
@@ -124,7 +125,7 @@ For more details, and available tasks, run:
124
125
  uv run poe --help
125
126
  ```
126
127
 
127
- ## UI Development Quick Start
128
+ ### UI Development Quick Start
128
129
 
129
130
  The UI is built with [Textual](https://textual.textualize.io/).
130
131
 
@@ -145,7 +146,7 @@ reflect changes immediately.
145
146
 
146
147
  Make sure you run Exosphere UI with `exosphere ui start`.
147
148
 
148
- ## Documentation Editing Quick Start
149
+ ### Documentation Editing Quick Start
149
150
 
150
151
  To edit the documentation, you can use the following commands:
151
152
 
@@ -171,6 +172,89 @@ documentation, but can also be invoked separately:
171
172
  uv run poe docs-lint
172
173
  ```
173
174
 
175
+ ### Project Structure
176
+
177
+ The project is managed via uv and `pyproject.toml`, which contains all dependencies,
178
+ scripts, and metadata for the application.
179
+
180
+ Exosphere uses [Poe the Poet](https://poethepoet.natn.io/) as a task runner, and all
181
+ tasks are defined in the `pyproject.toml` file under the `[tool.poe.tasks]` table.
182
+
183
+ #### Root Directory
184
+
185
+ | path | description |
186
+ | ---- | ----------- |
187
+ | `docs/` | Sphinx documentation source tree |
188
+ | `docs/source/_ext/` | Custom Sphinx extensions for the project |
189
+ | `examples/` | Example configuration files and reports |
190
+ | `scripts/` | Utilitarian scripts for dev and maintenance |
191
+ | `src/` | Main source code for the application |
192
+ | `tests/` | Test suite for the application |
193
+
194
+ #### Source Tree
195
+
196
+ | path | description |
197
+ | ---- | ----------- |
198
+ | `src/exosphere/` | Main application source code |
199
+ | `src/exosphere/commands/` | CLI command implementations |
200
+ | `src/exosphere/providers/` | Package Manager Provider implementations (e.g. debian, freebsd, redhat, etc) |
201
+ | `src/exosphere/schema/` | Reporting JSON schema definitions |
202
+ | `src/exosphere/setup/` | Discovery and platform detection module |
203
+ | `src/exosphere/templates/` | Jinja2 templates for reporting |
204
+ | `src/exosphere/ui/` | Textual UI source code |
205
+ | `src/exosphere/ui/style.tcss` | Textual CSS for styling the UI |
206
+
207
+ The rest of the source tree should be fairly self-explanatory.
208
+
209
+ #### Core Modules
210
+
211
+ Paths below are relative to `src/exosphere/` unless otherwise noted.
212
+
213
+ | module | description |
214
+ | ------ | ----------- |
215
+ | `main.py` | Main entry point for the application |
216
+ | `providers/api.py` | Package manager provider API and base classes |
217
+ | `providers/factory.py` | Concrete provider factory for creation of Package Managers |
218
+ | `cli.py` | CLI interface entry point |
219
+ | `config.py` | Configuration subsystem, including defaults |
220
+ | `context.py` | Context management for shared state across commands and UI |
221
+ | `data.py` | Data models and structures for serialization and exchange |
222
+ | `database.py` | Cache system for serialization |
223
+ | `errors.py` | Exception classes and general error messages |
224
+ | `inventory.py` | Inventory management subsystem |
225
+ | `migrations.py` | Cache format migration processes |
226
+ | `objects.py` | Main objects for representing Hosts, and most of the relevant logic |
227
+ | `pipelining.py` | SSH pipelining implementation, including reaper thread |
228
+ | `repl.py` | REPL module for interactive CLI usage |
229
+ | `reporting.py` | Reporting subsystem, including templates and formatters |
230
+ | `security.py` | Sudo management subsystem, including policy and utilities |
231
+
232
+ Generally, most of the things Exosphere does to hosts (including connection management
233
+ and operations) are going to be found in `objects.py`.
234
+
235
+ #### UI Modules
236
+
237
+ Paths below are relative to `src/exosphere/` unless otherwise noted.
238
+
239
+ | module | description |
240
+ | ------ | ----------- |
241
+ | `ui/app.py` | Main Textual application class and entry point for the UI |
242
+ | `ui/context.py` | UI Context management for shared state across UI components |
243
+ | `ui/elements.py` | Shared UI elements, including task runners |
244
+ | `ui/dashboard.py` | Dashboard view implementation |
245
+ | `ui/inventory.py` | Inventory view implementation |
246
+ | `ui/logs.py` | Logs view implementation |
247
+ | `ui/messages.py` | Screen refresh and message passing system |
248
+
249
+ The TCSS for all of it is in a single file under `ui/style.tcss`.
250
+
251
+ ### Using Exosphere as a Library
252
+
253
+ This use case is not currently well supported, but it is possible to use Exosphere as a library.
254
+ The documentation for this (alongside actual examples) is still a WIP, but you can
255
+ refer to the [Online API Documentation](https://exosphere.readthedocs.io/en/stable/api/index.html)
256
+ for the core functionality and objects that are considered public.
257
+
174
258
  ## License
175
259
 
176
260
  This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -50,7 +50,7 @@ Supported platforms for remote hosts include:
50
50
  - FreeBSD (using pkg)
51
51
  - OpenBSD (using pkg_add)
52
52
 
53
- Unsupported platform with with SSH connectivity checks only:
53
+ Unsupported platforms with SSH connectivity checks only:
54
54
 
55
55
  - Other Linux distributions (e.g., Arch Linux, Gentoo, NixOS, etc.)
56
56
  - Other BSD systems (NetBSD)
@@ -64,9 +64,11 @@ This includes network equipment with proprietary operating systems, etc.
64
64
  For installation instructions, configuration and usage examples,
65
65
  [full documentation](https://exosphere.readthedocs.io/) is available.
66
66
 
67
- ## Development Quick Start
67
+ ## Development
68
68
 
69
- tl;dr, use [uv](https://docs.astral.sh/uv/getting-started/installation/)
69
+ ### Development Quick Start
70
+
71
+ TL;DR, use [uv](https://docs.astral.sh/uv/getting-started/installation/)
70
72
 
71
73
  ```bash
72
74
  uv sync --dev
@@ -87,7 +89,7 @@ For more details, and available tasks, run:
87
89
  uv run poe --help
88
90
  ```
89
91
 
90
- ## UI Development Quick Start
92
+ ### UI Development Quick Start
91
93
 
92
94
  The UI is built with [Textual](https://textual.textualize.io/).
93
95
 
@@ -108,7 +110,7 @@ reflect changes immediately.
108
110
 
109
111
  Make sure you run Exosphere UI with `exosphere ui start`.
110
112
 
111
- ## Documentation Editing Quick Start
113
+ ### Documentation Editing Quick Start
112
114
 
113
115
  To edit the documentation, you can use the following commands:
114
116
 
@@ -134,6 +136,89 @@ documentation, but can also be invoked separately:
134
136
  uv run poe docs-lint
135
137
  ```
136
138
 
139
+ ### Project Structure
140
+
141
+ The project is managed via uv and `pyproject.toml`, which contains all dependencies,
142
+ scripts, and metadata for the application.
143
+
144
+ Exosphere uses [Poe the Poet](https://poethepoet.natn.io/) as a task runner, and all
145
+ tasks are defined in the `pyproject.toml` file under the `[tool.poe.tasks]` table.
146
+
147
+ #### Root Directory
148
+
149
+ | path | description |
150
+ | ---- | ----------- |
151
+ | `docs/` | Sphinx documentation source tree |
152
+ | `docs/source/_ext/` | Custom Sphinx extensions for the project |
153
+ | `examples/` | Example configuration files and reports |
154
+ | `scripts/` | Utilitarian scripts for dev and maintenance |
155
+ | `src/` | Main source code for the application |
156
+ | `tests/` | Test suite for the application |
157
+
158
+ #### Source Tree
159
+
160
+ | path | description |
161
+ | ---- | ----------- |
162
+ | `src/exosphere/` | Main application source code |
163
+ | `src/exosphere/commands/` | CLI command implementations |
164
+ | `src/exosphere/providers/` | Package Manager Provider implementations (e.g. debian, freebsd, redhat, etc) |
165
+ | `src/exosphere/schema/` | Reporting JSON schema definitions |
166
+ | `src/exosphere/setup/` | Discovery and platform detection module |
167
+ | `src/exosphere/templates/` | Jinja2 templates for reporting |
168
+ | `src/exosphere/ui/` | Textual UI source code |
169
+ | `src/exosphere/ui/style.tcss` | Textual CSS for styling the UI |
170
+
171
+ The rest of the source tree should be fairly self-explanatory.
172
+
173
+ #### Core Modules
174
+
175
+ Paths below are relative to `src/exosphere/` unless otherwise noted.
176
+
177
+ | module | description |
178
+ | ------ | ----------- |
179
+ | `main.py` | Main entry point for the application |
180
+ | `providers/api.py` | Package manager provider API and base classes |
181
+ | `providers/factory.py` | Concrete provider factory for creation of Package Managers |
182
+ | `cli.py` | CLI interface entry point |
183
+ | `config.py` | Configuration subsystem, including defaults |
184
+ | `context.py` | Context management for shared state across commands and UI |
185
+ | `data.py` | Data models and structures for serialization and exchange |
186
+ | `database.py` | Cache system for serialization |
187
+ | `errors.py` | Exception classes and general error messages |
188
+ | `inventory.py` | Inventory management subsystem |
189
+ | `migrations.py` | Cache format migration processes |
190
+ | `objects.py` | Main objects for representing Hosts, and most of the relevant logic |
191
+ | `pipelining.py` | SSH pipelining implementation, including reaper thread |
192
+ | `repl.py` | REPL module for interactive CLI usage |
193
+ | `reporting.py` | Reporting subsystem, including templates and formatters |
194
+ | `security.py` | Sudo management subsystem, including policy and utilities |
195
+
196
+ Generally, most of the things Exosphere does to hosts (including connection management
197
+ and operations) are going to be found in `objects.py`.
198
+
199
+ #### UI Modules
200
+
201
+ Paths below are relative to `src/exosphere/` unless otherwise noted.
202
+
203
+ | module | description |
204
+ | ------ | ----------- |
205
+ | `ui/app.py` | Main Textual application class and entry point for the UI |
206
+ | `ui/context.py` | UI Context management for shared state across UI components |
207
+ | `ui/elements.py` | Shared UI elements, including task runners |
208
+ | `ui/dashboard.py` | Dashboard view implementation |
209
+ | `ui/inventory.py` | Inventory view implementation |
210
+ | `ui/logs.py` | Logs view implementation |
211
+ | `ui/messages.py` | Screen refresh and message passing system |
212
+
213
+ The TCSS for all of it is in a single file under `ui/style.tcss`.
214
+
215
+ ### Using Exosphere as a Library
216
+
217
+ This use case is not currently well supported, but it is possible to use Exosphere as a library.
218
+ The documentation for this (alongside actual examples) is still a WIP, but you can
219
+ refer to the [Online API Documentation](https://exosphere.readthedocs.io/en/stable/api/index.html)
220
+ for the core functionality and objects that are considered public.
221
+
137
222
  ## License
138
223
 
139
224
  This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "exosphere-cli"
3
- version = "2.4.0"
3
+ version = "2.4.2"
4
4
  description = "CLI/TUI driven patch reporting for remote Unix-like systems."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -11,7 +11,6 @@ classifiers = [
11
11
  "Development Status :: 5 - Production/Stable",
12
12
  "Environment :: Console",
13
13
  "Intended Audience :: System Administrators",
14
- "License :: OSI Approved :: MIT License",
15
14
  "Natural Language :: English",
16
15
  "Operating System :: OS Independent",
17
16
  "Programming Language :: Python :: 3.13",
@@ -49,14 +48,14 @@ dev = [
49
48
  "pytest-cov>=6.1.1",
50
49
  "pytest-json-ctrf>=0.3.5",
51
50
  "pytest-mock>=3.14.1",
52
- "renku-sphinx-theme>=0.5.0",
51
+ "sphinx-rtd-theme>=3.0.2",
53
52
  "ruff>=0.15.0",
54
- "sphinx>=8.2.3,<9.0",
53
+ "sphinx>=8.2.3",
55
54
  "sphinx-autobuild>=2024.10.3",
56
55
  "sphinx-lint>=1.0.0",
57
56
  "sphinx-tabs>=3.4.7",
58
57
  "sphinxcontrib-spelling>=8.0.1",
59
- "sphinxcontrib-typer>=0.5.1",
58
+ "sphinxcontrib-typer>=0.7.2",
60
59
  "textual-dev>=1.7.0",
61
60
  ]
62
61
 
@@ -74,7 +73,7 @@ issues = "https://github.com/mrdaemon/exosphere/issues"
74
73
  exosphere = "exosphere.main:main"
75
74
 
76
75
  [build-system]
77
- requires = ["uv_build>=0.7.19,<0.11.0"]
76
+ requires = ["uv_build>=0.7.19,<0.12.0"]
78
77
  build-backend = "uv_build"
79
78
 
80
79
  [tool.uv.build-backend]
@@ -16,6 +16,18 @@ AUTH_FAILURE_MESSAGE = (
16
16
  "and that your username is correct for the host."
17
17
  )
18
18
 
19
+ # Sudo authentication failure message for better UX
20
+ # This is intended to be displayed whenever a sudo command fails due to
21
+ # a password prompt, or failure raised by Invoke's AuthFailure exception.
22
+ # This will generally come up when a user is not configured with passwordless
23
+ # sudo, but has a Sudo Policy of NOPASSWD or equivalent.
24
+ SUDO_AUTH_FAILURE_MESSAGE = (
25
+ "Sudo failed: "
26
+ "Ensure the user is configured with passwordless sudo. "
27
+ "You can use 'exosphere sudo generate' to produce a sudoers snippet for this host. "
28
+ "See: https://exosphere.readthedocs.io/en/stable/connections.html#id1"
29
+ )
30
+
19
31
 
20
32
  class DataRefreshError(Exception):
21
33
  """Exception raised for errors encountered during data refresh."""
@@ -15,14 +15,7 @@ from fabric import Connection
15
15
  from invoke.exceptions import AuthFailure
16
16
 
17
17
  from exosphere.data import Update
18
- from exosphere.errors import DataRefreshError
19
-
20
- _SUDO_AUTH_FAILURE_MESSAGE = (
21
- "Sudo failed: "
22
- "Ensure the user is configured with passwordless sudo. "
23
- "You can use 'exosphere sudo generate' to produce a sudoers snippet for this host. "
24
- "See: https://exosphere.readthedocs.io/en/stable/connections.html#id1"
25
- )
18
+ from exosphere.errors import SUDO_AUTH_FAILURE_MESSAGE, DataRefreshError
26
19
 
27
20
 
28
21
  def requires_sudo(func: Callable) -> Callable:
@@ -44,7 +37,7 @@ def requires_sudo(func: Callable) -> Callable:
44
37
  try:
45
38
  return func(*args, **kwargs)
46
39
  except AuthFailure as e:
47
- raise DataRefreshError(_SUDO_AUTH_FAILURE_MESSAGE) from e
40
+ raise DataRefreshError(SUDO_AUTH_FAILURE_MESSAGE) from e
48
41
 
49
42
  setattr(wrapper, "__requires_sudo", True)
50
43
  return wrapper
@@ -28,6 +28,7 @@ class Apt(PkgManager):
28
28
  """
29
29
  super().__init__()
30
30
  self.logger.debug("Initializing Debian Apt package manager")
31
+ self.line_pattern: re.Pattern | None = None
31
32
 
32
33
  @requires_sudo
33
34
  def reposync(self, cx: Connection) -> bool:
@@ -49,6 +50,10 @@ class Apt(PkgManager):
49
50
  )
50
51
  return False
51
52
 
53
+ # Log warnings from apt-get if there was any stderr output.
54
+ if update.stderr:
55
+ self._log_apt_warn(update.stderr)
56
+
52
57
  self.logger.debug("Apt repositories synchronized successfully")
53
58
 
54
59
  return True
@@ -78,6 +83,10 @@ class Apt(PkgManager):
78
83
  self.logger.debug("No updates available or no matches in output.")
79
84
  return updates
80
85
 
86
+ # Log warnings from apt-get if there was any stderr output.
87
+ if raw_query.stderr:
88
+ self._log_apt_warn(raw_query.stderr)
89
+
81
90
  for line in raw_query.stdout.splitlines():
82
91
  line = line.strip()
83
92
 
@@ -108,15 +117,17 @@ class Apt(PkgManager):
108
117
  :return: Update data class instance or None if parsing fails.
109
118
  """
110
119
 
111
- pattern = (
112
- r"^Inst\s+" # Starts with "Inst" followed by space(s)
113
- r"(?P<name>\S+)\s+" # Package name: non-space characters
114
- r"(?:\[(?P<current_version>[^\]]+)\]\s+)?" # Current version: text in [] (optional)
115
- r"\((?P<new_version>\S+)\s+" # New version: first non-space in ()
116
- r"(?P<source>.+?)\s+\[[^\]]+\]\)" # Repo source: lazily capture text until next [..]
117
- )
120
+ # Compile the regex pattern on first use
121
+ if self.line_pattern is None:
122
+ self.line_pattern = re.compile(
123
+ r"^Inst\s+" # Starts with "Inst" followed by space(s)
124
+ r"(?P<name>\S+)\s+" # Package name: non-space characters
125
+ r"(?:\[(?P<current_version>[^\]]+)\]\s+)?" # Current version: text in [] (optional)
126
+ r"\((?P<new_version>\S+)\s+" # New version: first non-space in ()
127
+ r"(?P<source>.+?)\s+\[[^\]]+\]\)" # Repo source: lazily capture text until next [..]
128
+ )
118
129
 
119
- match = re.match(pattern, line)
130
+ match = self.line_pattern.match(line)
120
131
 
121
132
  if not match:
122
133
  return None
@@ -135,7 +146,7 @@ class Apt(PkgManager):
135
146
  repo_source = match["source"].strip()
136
147
  is_security = False
137
148
 
138
- if "security" in repo_source.lower():
149
+ if "security" in repo_source.casefold():
139
150
  self.logger.debug(
140
151
  f"Package {package_name} is a security update: {new_version}"
141
152
  )
@@ -148,3 +159,16 @@ class Apt(PkgManager):
148
159
  source=repo_source,
149
160
  security=is_security,
150
161
  )
162
+
163
+ def _log_apt_warn(self, lines: str) -> None:
164
+ """
165
+ Log warnings from APT stderr output.
166
+ We ignore empty lines and lines that do not begin with "W:"
167
+
168
+ :param lines: Stderr output to log.
169
+ """
170
+ for line in lines.splitlines():
171
+ line = line.strip()
172
+ if not line or not line.startswith("W: "):
173
+ continue
174
+ self.logger.warning("APT: %s", line)
@@ -36,6 +36,7 @@ class Pkg(PkgManager):
36
36
  super().__init__()
37
37
  self.logger.debug("Initializing FreeBSD pkg package manager")
38
38
  self.vulnerable: list[str] = []
39
+ self.line_pattern: re.Pattern | None = None
39
40
 
40
41
  @requires_sudo
41
42
  def reposync(self, cx: Connection) -> bool:
@@ -169,17 +170,20 @@ class Pkg(PkgManager):
169
170
  Also extracts the repository name in case of recent versions of pkg.
170
171
  """
171
172
 
172
- pattern = (
173
- r"^\s*(?P<name>\S+):\s+" # Package name, followed by colon and spaces
174
- r"(?P<version>[^\s]+)" # Current version: non-space characters
175
- r"(?:" # Start of alternation group
176
- r"(?:\s+->\s+(?P<new>[^\s]+))?" # Optional separator and new version
177
- r")" # End of alternation group
178
- r"(?:\s*\[(?P<repo>.*?)\])?" # Optional repo tag in brackets (e.g., [FreeBSD])
179
- r"$" # End of line
180
- )
173
+ # Compile the regex pattern on first use
174
+ if self.line_pattern is None:
175
+ self.line_pattern = re.compile(
176
+ r"^\s*(?P<name>\S+):\s+" # Package name, followed by colon and spaces
177
+ r"(?P<version>[^\s]+)" # Current version: non-space characters
178
+ r"(?:" # Start of alternation group
179
+ r"(?:\s+->\s+(?P<new>[^\s]+))?" # Optional separator and new version
180
+ r")" # End of alternation group
181
+ r"(?:\s*\[(?P<repo>.*?)\])?" # Optional repo tag in brackets (e.g., [FreeBSD])
182
+ r"$" # End of line
183
+ )
184
+
185
+ match = self.line_pattern.match(line)
181
186
 
182
- match = re.match(pattern, line)
183
187
  if not match:
184
188
  return None
185
189
 
@@ -30,6 +30,7 @@ class PkgAdd(PkgManager):
30
30
  """
31
31
  super().__init__()
32
32
  self.logger.debug("Initializing OpenBSD pkg_add package manager")
33
+ self.line_pattern: re.Pattern | None = None
33
34
 
34
35
  def reposync(self, cx: Connection) -> bool:
35
36
  """
@@ -67,7 +68,7 @@ class PkgAdd(PkgManager):
67
68
  # Syspatch returns non-zero exit code if the release is unsupported,
68
69
  # and we use this to determine if we can track security status.
69
70
  if version_query.failed:
70
- if "unsupported release" in version_query.stderr.lower():
71
+ if "unsupported release" in version_query.stderr.casefold():
71
72
  self.logger.warning(
72
73
  "Host is running unsupported OpenBSD release. "
73
74
  "Security status will not be tracked.",
@@ -139,9 +140,13 @@ class PkgAdd(PkgManager):
139
140
  :return: An Update object or None if not an update
140
141
  """
141
142
 
142
- pattern = r"^Update candidates: ([\w\-.+]+)-([^\s]+) -> ([\w\-.+]+)-([^\s]+)$"
143
+ # Compile the regex pattern on first use
144
+ if self.line_pattern is None:
145
+ self.line_pattern = re.compile(
146
+ r"^Update candidates: ([\w\-.+]+)-([^\s]+) -> ([\w\-.+]+)-([^\s]+)$"
147
+ )
143
148
 
144
- match = re.match(pattern, line)
149
+ match = self.line_pattern.match(line)
145
150
 
146
151
  if not match:
147
152
  self.logger.debug("Could not parse: %s", line)
@@ -2,6 +2,8 @@
2
2
  RedHat Package Manager Provider
3
3
  """
4
4
 
5
+ import re
6
+
5
7
  from fabric import Connection
6
8
 
7
9
  from exosphere.data import Update
@@ -16,9 +18,14 @@ class Dnf(PkgManager):
16
18
  Implements the DNF package manager interface.
17
19
  Can also be used as a drop-in replacement for YUM.
18
20
 
19
- The whole RPM ecosystem is kind of a piece of shit in terms of
20
- integration between high level and low level interfaces.
21
- It is what it is.
21
+ Some limitations:
22
+ - Current Version data is provided on a Best Effort basis
23
+ - Slotted/installonly packages especially are clobbered down to a
24
+ single version
25
+ - Some broken kernel repo configurations may cause kernel updates
26
+ to not be displayed, but this is a Vendor Specific issue, and
27
+ working around it is not worth the effort as it introduces new
28
+ and exciting issues for systems where this is not the case.
22
29
  """
23
30
 
24
31
  def __init__(self, use_yum: bool = False) -> None:
@@ -31,6 +38,7 @@ class Dnf(PkgManager):
31
38
  super().__init__()
32
39
  self.logger.debug("Initializing RedHat DNF package manager")
33
40
  self.security_updates: list[str] = []
41
+ self.line_pattern: re.Pattern | None = None
34
42
 
35
43
  def reposync(self, cx: Connection) -> bool:
36
44
  """
@@ -67,13 +75,7 @@ class Dnf(PkgManager):
67
75
  # Get security updates first
68
76
  self.security_updates = self._get_security_updates(cx)
69
77
 
70
- # Get kernel updates second
71
- kernel_update = self._get_kernel_updates(cx)
72
- if kernel_update:
73
- self.logger.debug("A new kernel is available.")
74
- updates.append(kernel_update)
75
-
76
- # Get all other updates
78
+ # Get all updates
77
79
  raw_query = cx.run(
78
80
  f"{self.pkgbin} --quiet -y check-update", hide=True, warn=True
79
81
  )
@@ -98,12 +100,19 @@ class Dnf(PkgManager):
98
100
  continue
99
101
 
100
102
  # Stop processing at "Obsoleting Packages" section
101
- if "obsoleting packages" in line.lower():
103
+ if "obsoleting packages" in line.casefold():
102
104
  self.logger.debug(
103
105
  "Reached 'Obsoleting Packages' section, stopping parsing."
104
106
  )
105
107
  break
106
108
 
109
+ # Skip Security: annotation lines emitted by dnf when
110
+ # some intermediate security updates are installed but not active,
111
+ # or similar scenarios
112
+ if line.casefold().startswith("security:"):
113
+ self.logger.debug("Skipping security annotation line: %s", line)
114
+ continue
115
+
107
116
  parsed = self._parse_line(line)
108
117
  if parsed is None:
109
118
  self.logger.debug("Failed to parse line: %s. Skipping.", line)
@@ -115,38 +124,25 @@ class Dnf(PkgManager):
115
124
 
116
125
  self.logger.debug("Found %d update(s)", len(parsed_tuples))
117
126
 
118
- installed_versions = self._get_current_versions(
127
+ # Skip the rest of processing if no parsable updates were found.
128
+ # This likely indicates an issue with the system, unexpected
129
+ # output, or an issue with our parser needing adjustments.
130
+ if not parsed_tuples:
131
+ self.logger.warning(
132
+ "%s reported updates, but none were extracted! "
133
+ "This is likely a bug, consider filing an issue." % self.pkgbin.upper()
134
+ )
135
+ return updates
136
+
137
+ installed_versions = self._get_current_version(
119
138
  cx, [name for name, _, _ in parsed_tuples]
120
139
  )
121
140
 
122
141
  for name, version, source in parsed_tuples:
123
- # If update was provided by security or kernel checks, skip it here
124
- # Whether it shows up in both lists depends on configuration and
125
- # this varies from specific flavor to flavor.
126
- if name in [u.name for u in updates]:
127
- self.logger.debug(
128
- "Update for %s is already in the list, skipping", name
129
- )
130
- continue
131
-
132
142
  is_security = name in self.security_updates
133
143
 
134
144
  current_version = installed_versions.get(name, None)
135
145
 
136
- # Handle slotted packages, if they show up for any reason.
137
- # We only handle the kernel package as slotted, but it may show
138
- # up here in some edge cases or configurations.
139
- if isinstance(current_version, list):
140
- self.logger.debug(
141
- "Slotted package %s has multiple versions: %s",
142
- name,
143
- current_version,
144
- )
145
- current_version = current_version[-1] if current_version else None
146
- self.logger.debug(
147
- "Using version %s for currently installed.", current_version
148
- )
149
-
150
146
  update = Update(
151
147
  name=name,
152
148
  current_version=current_version,
@@ -159,89 +155,6 @@ class Dnf(PkgManager):
159
155
 
160
156
  return updates
161
157
 
162
- def _get_kernel_updates(self, cx: Connection) -> Update | None:
163
- """
164
- Get latest kernel update if it differs from installed
165
-
166
- This is a separate step due to the way redhat systems usually
167
- manage kernel images. The packages are essentially slotted,
168
- and they are New Packages, not straight upgrades in any
169
- meaningful way.
170
- """
171
- self.logger.debug("Querying repository for latest kernel")
172
-
173
- # Format the output to match 'check-update'
174
- queryformat = "%{name}.%{arch} %{version}-%{release} %{repoid}\n"
175
-
176
- raw_query = cx.run(
177
- f"{self.pkgbin} --quiet -y repoquery kernel --latest-limit=1 --queryformat='{queryformat}'",
178
- hide=True,
179
- warn=True,
180
- )
181
-
182
- if raw_query.failed:
183
- raise DataRefreshError(
184
- f"Failed to retrieve latest kernel from repo: {raw_query.stderr}"
185
- )
186
-
187
- latest: tuple[str, str, str] | None = None
188
-
189
- for line in raw_query.stdout.splitlines():
190
- line = line.strip()
191
-
192
- if not line:
193
- continue
194
-
195
- parsed = self._parse_line(line)
196
-
197
- if not parsed:
198
- self.logger.debug("Failed to parse line: %s. Skipping.", line)
199
- continue
200
-
201
- latest = parsed
202
- break # Only one result is expected anyways.
203
-
204
- if not latest:
205
- self.logger.warning("Repo query did not return a kernel, skipping check")
206
- return None
207
-
208
- latest_name, latest_version, latest_source = latest
209
- self.logger.debug("Latest version is %s", latest_version)
210
-
211
- self.logger.debug("Checking installed kernels")
212
- installed_kernels = self._get_current_versions(cx, ["kernel"])
213
-
214
- if not installed_kernels:
215
- self.logger.warning("No installed kernels found? This is likely a bug.")
216
- return None
217
-
218
- # Kernel packages are ALWAYS slotted, and will always return a list
219
- installed_versions = [v for k in installed_kernels.values() for v in k]
220
-
221
- if latest_version not in installed_versions:
222
- # We can generally assume that if a kernel package is
223
- # present in security updates, it's going to be this one,
224
- # even though we don't explicitly check and compare the versions.
225
- is_security: bool = latest_name in self.security_updates
226
-
227
- self.logger.debug("Found new kernel: %s", latest_version)
228
-
229
- self.logger.debug(
230
- "Kernel %s is %s",
231
- latest_version,
232
- "security" if is_security else "not security",
233
- )
234
-
235
- return Update(
236
- name=latest_name,
237
- current_version=installed_versions[-1] if installed_versions else None,
238
- new_version=latest_version,
239
- source=latest_source,
240
- security=is_security,
241
- )
242
-
243
- return None
244
-
245
158
  def _get_security_updates(self, cx: Connection) -> list[str]:
246
159
  """
247
160
  Get updates marked as security from dnf
@@ -275,12 +188,16 @@ class Dnf(PkgManager):
275
188
  continue
276
189
 
277
190
  # Stop processing at "Obsoleting Packages" section
278
- if line.startswith("Obsoleting Packages"):
191
+ if line.casefold().startswith("obsoleting packages"):
279
192
  self.logger.debug(
280
193
  "Reached 'Obsoleting Packages' section, stopping parsing."
281
194
  )
282
195
  break
283
196
 
197
+ # Skip Security: annotation lines
198
+ if line.casefold().startswith("security:"):
199
+ continue
200
+
284
201
  parsed = self._parse_line(line)
285
202
  if parsed:
286
203
  name, version, source = parsed
@@ -291,40 +208,52 @@ class Dnf(PkgManager):
291
208
 
292
209
  def _parse_line(self, line: str) -> tuple[str, str, str] | None:
293
210
  """
294
- Parse a line from the DNF output to create an Update object.
211
+ Parse a line from DNF check-update style tabular output
212
+ Extracts Update object data as a tuple.
295
213
 
296
214
  :param line: Line from DNF output.
297
215
  :return: Tuple of (name, version, source) or None if parsing fails.
298
216
  """
299
- parts = line.split()
300
217
 
301
- if len(parts) < 3:
302
- self.logger.debug("Line does not contain enough parts: %s", line)
218
+ # Lazy compile the line pattern on first use
219
+ # Repository source component is usually in the form of "reponame"
220
+ # but can be prefixed with "@" or in the case of missing metadata,
221
+ # be the string "<unknown>" or similar. We account for these here.
222
+ if self.line_pattern is None:
223
+ self.line_pattern = re.compile(
224
+ r"^(?P<name>[a-z0-9][\w+.-]*\.\w+)\s+" # Package (name.arch)
225
+ r"(?P<version>[\w.+~:-]+-[\w.+~]+)\s+" # RPM version-release
226
+ r"(?P<source>@?[a-z0-9][\w.:+/-]*|<[^>\s]+>)$", # Repo source (optional @ or <>
227
+ re.ASCII | re.IGNORECASE,
228
+ )
229
+
230
+ match = self.line_pattern.match(line)
231
+
232
+ if not match:
233
+ self.logger.debug("Skipping garbage line: %s", line)
303
234
  return None
304
235
 
305
- name = parts[0]
306
- version = parts[1]
307
- source = parts[2]
236
+ # Cleanup source string before sending it up
237
+ source = match["source"].removeprefix("@").strip("<>")
308
238
 
309
- return (name, version, source)
239
+ return (match["name"], match["version"], source)
310
240
 
311
- def _get_current_versions(
241
+ def _get_current_version(
312
242
  self, cx: Connection, package_names: list[str]
313
- ) -> dict[str, str | list[str]]:
243
+ ) -> dict[str, str]:
314
244
  """
315
245
  Get the currently installed version of a package.
316
246
 
317
- Kernel packages are handled specially since they are slotted.
318
- We don't generally care about slotted packages and just clobber it
319
- down to a single version, but kernel packages are of interest.
247
+ This method clobbers packages down to a single version.
248
+ For installonly/slotted packages with multiple installed versions,
249
+ the last reported version is used.
320
250
 
321
- If 'kernel' is in the package_names, the value will be
322
- a list of versions.
251
+ This is done on a best effort basis, but works well enough
252
+ given the limitations of the DNF/YUM interfaces.
323
253
 
324
254
  :param cx: Fabric Connection object.
325
255
  :param package_names: Package names to return versions for.
326
- :return: Currently installed version of the package, or
327
- list of installed versions if kernel package.
256
+ :return: Currently installed version of each package.
328
257
  """
329
258
 
330
259
  result = cx.run(
@@ -340,14 +269,14 @@ class Dnf(PkgManager):
340
269
 
341
270
  for line in result.stdout.splitlines():
342
271
  line = line.strip()
343
- if not line or "installed packages" in line.lower():
272
+ if not line or "installed packages" in line.casefold():
344
273
  continue
345
274
 
346
275
  # Stop parsing at "Available packages" section
347
276
  # This is for DNF5 compatibility, which helpfully lists them
348
277
  # and our clobbering logic prevents current version logic from
349
278
  # working.
350
- if "available packages" in line.lower():
279
+ if "available packages" in line.casefold():
351
280
  self.logger.debug(
352
281
  "Reached 'Available packages' section, stopping parsing."
353
282
  )
@@ -363,25 +292,18 @@ class Dnf(PkgManager):
363
292
 
364
293
  existing_key = current_versions.get(name)
365
294
 
366
- # Kernel packages are slotted and we want to keep all of them.
367
- if name.split(".")[0] == "kernel":
368
- if not existing_key:
369
- current_versions[name] = [version]
370
- else:
371
- current_versions[name].append(version)
372
- else:
373
- # Everything else gets clobbered because slotted packages
374
- # generally don't matter from a UX standpoint. We just need
375
- # the last version in the results.
376
- if existing_key:
377
- self.logger.debug(
378
- "Clobbering %s with %s for package %s",
379
- existing_key,
380
- version,
381
- name,
382
- )
383
-
384
- current_versions[name] = version
295
+ # Clobber packages down to a single version
296
+ # This handles slotted/installonly packages where multiple
297
+ # versions may be installed concurrently.
298
+ if existing_key:
299
+ self.logger.debug(
300
+ "Clobbering %s with %s for package %s",
301
+ existing_key,
302
+ version,
303
+ name,
304
+ )
305
+
306
+ current_versions[name] = version
385
307
 
386
308
  self.logger.debug("Current versions: %s", current_versions)
387
309
  return current_versions
@@ -400,10 +322,5 @@ class Yum(Dnf):
400
322
  def __init__(self) -> None:
401
323
  """
402
324
  Initialize the Yum package manager.
403
-
404
- :param sudo: Whether to use sudo for package refresh operations
405
- (default is True).
406
- :param password: Optional password for sudo operations, if not
407
- using NOPASSWD.
408
325
  """
409
326
  super().__init__(use_yum=True)
@@ -30,6 +30,10 @@ def platform_detect(cx: Connection) -> HostInfo:
30
30
  Detect the platform of the remote system.
31
31
  Entry point for refreshing all platform details.
32
32
 
33
+ Linux detection in general depends on the presence of a well-formed
34
+ /etc/os-release file, which is a safe assumption for everything we
35
+ explicitly support that's not EOL'd.
36
+
33
37
  :param cx: Fabric Connection object
34
38
  :return: HostInfo object with platform details
35
39
  """
@@ -114,8 +118,6 @@ def flavor_detect(cx: Connection, platform_name: str) -> str:
114
118
 
115
119
  # Linux
116
120
  if platform_name == "linux":
117
- # We're just going to query /etc/os-release directly.
118
- # Using lsb_release would be better, but it's less available
119
121
  result_id = cx.run("grep ^ID= /etc/os-release", hide=True, warn=True)
120
122
  result_like_id = cx.run(
121
123
  "grep ^ID_LIKE= /etc/os-release",
@@ -125,7 +127,7 @@ def flavor_detect(cx: Connection, platform_name: str) -> str:
125
127
 
126
128
  if result_id.failed:
127
129
  raise DataRefreshError(
128
- "Failed to detect OS flavor via lsb identifier.",
130
+ "Failed to detect OS flavor via os-release identifier.",
129
131
  stderr=result_id.stderr,
130
132
  stdout=result_id.stdout,
131
133
  )
@@ -187,36 +189,31 @@ def version_detect(cx: Connection, flavor_name: str) -> str:
187
189
  if flavor_name.lower() not in SUPPORTED_FLAVORS:
188
190
  raise UnsupportedOSError(f"Unsupported OS flavor: {flavor_name}")
189
191
 
190
- # Debian/Ubuntu
191
- if flavor_name in ["ubuntu", "debian"]:
192
- result_version = cx.run("lsb_release -s -r", hide=True, warn=True)
193
-
194
- if result_version.failed:
195
- raise DataRefreshError(
196
- "Failed to detect OS version via lsb_release.",
197
- stderr=result_version.stderr,
198
- stdout=result_version.stdout,
192
+ # Linux flavors that rely on os-release metadata
193
+ # (which is all of them, at the time of writing)
194
+ if flavor_name in ["ubuntu", "debian", "rhel", "fedora"]:
195
+ result_version = None
196
+
197
+ # Some systems (debian sid, for instance) don't provide VERSION_ID,
198
+ # So we fall back to VERSION_CODENAME in these cases.
199
+ # If neither work, we just err on the side of failure.
200
+ for version_key in ["VERSION_ID", "VERSION_CODENAME"]:
201
+ result_version = cx.run(
202
+ f"grep ^{version_key}= /etc/os-release", hide=True, warn=True
199
203
  )
200
204
 
201
- return result_version.stdout.strip()
202
-
203
- # Redhat-likes
204
- if flavor_name in ["rhel", "fedora"]:
205
- result_version = cx.run(
206
- "grep ^VERSION_ID= /etc/os-release", hide=True, warn=True
207
- )
208
-
209
- if result_version.failed:
210
- raise DataRefreshError(
211
- "Failed to detect OS version via os-release VERSION_ID.",
212
- stderr=result_version.stderr,
213
- stdout=result_version.stdout,
214
- )
205
+ if not result_version.failed:
206
+ logger.debug("Found version using %s", version_key)
207
+ version_line = result_version.stdout.strip()
208
+ version_value = version_line.partition("=")[2].strip().strip("\"'")
215
209
 
216
- version_line = result_version.stdout.strip()
217
- version_value = version_line.partition("=")[2].strip().strip("\"'")
210
+ return version_value
218
211
 
219
- return version_value.lower()
212
+ raise DataRefreshError(
213
+ "Failed to detect OS version via os-release VERSION_ID or VERSION_CODENAME.",
214
+ stderr=result_version.stderr if result_version else "",
215
+ stdout=result_version.stdout if result_version else "",
216
+ )
220
217
 
221
218
  # FreeBSD
222
219
  if flavor_name == "freebsd":
File without changes