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.
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/PKG-INFO +91 -7
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/README.md +90 -5
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/pyproject.toml +5 -6
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/errors.py +12 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/providers/api.py +2 -9
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/providers/debian.py +33 -9
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/providers/freebsd.py +14 -10
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/providers/openbsd.py +8 -3
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/providers/redhat.py +79 -162
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/setup/detect.py +26 -29
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/LICENSE +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/__init__.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/cli.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/commands/__init__.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/commands/config.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/commands/connections.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/commands/host.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/commands/inventory.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/commands/report.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/commands/sudo.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/commands/ui.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/commands/utils.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/commands/version.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/config.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/context.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/data.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/database.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/fspaths.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/inventory.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/main.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/migrations.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/objects.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/pipelining.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/providers/__init__.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/providers/factory.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/repl.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/reporting.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/schema/__init__.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/schema/host-report.schema.json +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/security.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/setup/__init__.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/templates/report.html.j2 +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/templates/report.md.j2 +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/templates/report.txt.j2 +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/ui/__init__.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/ui/app.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/ui/context.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/ui/dashboard.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/ui/elements.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/ui/inventory.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/ui/logs.py +0 -0
- {exosphere_cli-2.4.0 → exosphere_cli-2.4.2}/src/exosphere/ui/messages.py +0 -0
- {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.
|
|
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
|
|
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
|
|
103
|
+
## Development
|
|
105
104
|
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
67
|
+
## Development
|
|
68
68
|
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
51
|
+
"sphinx-rtd-theme>=3.0.2",
|
|
53
52
|
"ruff>=0.15.0",
|
|
54
|
-
"sphinx>=8.2.3
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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("
|
|
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
|
|
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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
|
241
|
+
def _get_current_version(
|
|
312
242
|
self, cx: Connection, package_names: list[str]
|
|
313
|
-
) -> dict[str, str
|
|
243
|
+
) -> dict[str, str]:
|
|
314
244
|
"""
|
|
315
245
|
Get the currently installed version of a package.
|
|
316
246
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
#
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
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
|
-
#
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
217
|
-
version_value = version_line.partition("=")[2].strip().strip("\"'")
|
|
210
|
+
return version_value
|
|
218
211
|
|
|
219
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|