osbuild-bpkit 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. osbuild_bpkit-0.2.0/.github/workflows/ci.yml +81 -0
  2. osbuild_bpkit-0.2.0/.github/workflows/docs.yml +61 -0
  3. osbuild_bpkit-0.2.0/.github/workflows/publish.yml +47 -0
  4. osbuild_bpkit-0.2.0/.gitignore +9 -0
  5. osbuild_bpkit-0.2.0/PKG-INFO +392 -0
  6. osbuild_bpkit-0.2.0/README.md +375 -0
  7. osbuild_bpkit-0.2.0/docs/guide/__init__.py +6 -0
  8. osbuild_bpkit-0.2.0/docs/guide/audit.py +5 -0
  9. osbuild_bpkit-0.2.0/docs/guide/basics.py +5 -0
  10. osbuild_bpkit-0.2.0/docs/guide/cli.py +5 -0
  11. osbuild_bpkit-0.2.0/docs/guide/gotchas.py +4 -0
  12. osbuild_bpkit-0.2.0/docs/guide/instances.py +5 -0
  13. osbuild_bpkit-0.2.0/docs/guide/overlays.py +5 -0
  14. osbuild_bpkit-0.2.0/docs/guide.md +397 -0
  15. osbuild_bpkit-0.2.0/examples/fleet.py +97 -0
  16. osbuild_bpkit-0.2.0/examples/overlays/base/etc/motd.d/10-banner +1 -0
  17. osbuild_bpkit-0.2.0/examples/overlays/base/etc/ssh/sshd_config.d/50-hardening.conf +2 -0
  18. osbuild_bpkit-0.2.0/examples/overlays/web/etc/motd.d/10-banner +1 -0
  19. osbuild_bpkit-0.2.0/examples/overlays/web/etc/nginx/conf.d/site.conf +4 -0
  20. osbuild_bpkit-0.2.0/examples/overlays/web/usr/local/bin/healthcheck +2 -0
  21. osbuild_bpkit-0.2.0/examples/overlays/web/var/www/html/static/.gitkeep +0 -0
  22. osbuild_bpkit-0.2.0/pyproject.toml +45 -0
  23. osbuild_bpkit-0.2.0/src/bpkit/__init__.py +51 -0
  24. osbuild_bpkit-0.2.0/src/bpkit/audit.py +1008 -0
  25. osbuild_bpkit-0.2.0/src/bpkit/blueprint.py +395 -0
  26. osbuild_bpkit-0.2.0/src/bpkit/build.py +206 -0
  27. osbuild_bpkit-0.2.0/src/bpkit/cli.py +605 -0
  28. osbuild_bpkit-0.2.0/src/bpkit/composer.py +280 -0
  29. osbuild_bpkit-0.2.0/src/bpkit/hosted.py +514 -0
  30. osbuild_bpkit-0.2.0/src/bpkit/importer.py +542 -0
  31. osbuild_bpkit-0.2.0/src/bpkit/markers.py +92 -0
  32. osbuild_bpkit-0.2.0/src/bpkit/merge.py +749 -0
  33. osbuild_bpkit-0.2.0/src/bpkit/models.py +650 -0
  34. osbuild_bpkit-0.2.0/src/bpkit/overlay.py +267 -0
  35. osbuild_bpkit-0.2.0/src/bpkit/py.typed +0 -0
  36. osbuild_bpkit-0.2.0/src/bpkit/render.py +111 -0
  37. osbuild_bpkit-0.2.0/src/bpkit/sbom.py +220 -0
  38. osbuild_bpkit-0.2.0/tests/conftest.py +403 -0
  39. osbuild_bpkit-0.2.0/tests/golden/minimal-web.toml +114 -0
  40. osbuild_bpkit-0.2.0/tests/golden/staging-web.toml +126 -0
  41. osbuild_bpkit-0.2.0/tests/golden/web-server.toml +122 -0
  42. osbuild_bpkit-0.2.0/tests/test_audit.py +374 -0
  43. osbuild_bpkit-0.2.0/tests/test_cli.py +95 -0
  44. osbuild_bpkit-0.2.0/tests/test_docs.py +50 -0
  45. osbuild_bpkit-0.2.0/tests/test_golden.py +61 -0
  46. osbuild_bpkit-0.2.0/tests/test_importer.py +157 -0
  47. osbuild_bpkit-0.2.0/tests/test_inheritance.py +433 -0
  48. osbuild_bpkit-0.2.0/tests/test_instances.py +157 -0
  49. osbuild_bpkit-0.2.0/tests/test_models.py +144 -0
  50. osbuild_bpkit-0.2.0/tests/test_overlay.py +300 -0
  51. osbuild_bpkit-0.2.0/tests/test_remote.py +376 -0
  52. osbuild_bpkit-0.2.0/tests/test_render.py +177 -0
  53. osbuild_bpkit-0.2.0/tests/test_sbom.py +76 -0
@@ -0,0 +1,81 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ concurrency:
13
+ group: ci-${{ github.ref }}
14
+ cancel-in-progress: true
15
+
16
+ env:
17
+ # Blueprint modules (or module:Class targets) to audit, space-separated.
18
+ AUDIT_TARGETS: examples/fleet.py
19
+ # Add --strict to fail on warnings as well as errors.
20
+ AUDIT_ARGS: ""
21
+
22
+ jobs:
23
+ lint:
24
+ runs-on: ubuntu-latest
25
+ steps:
26
+ - uses: actions/checkout@v5
27
+ - uses: actions/setup-python@v6
28
+ with:
29
+ python-version: "3.13"
30
+ cache: pip
31
+ - run: pip install -e ".[dev]"
32
+ - name: ruff check
33
+ run: ruff check --output-format=github .
34
+ - name: ruff format
35
+ run: ruff format --check .
36
+
37
+ test:
38
+ runs-on: ubuntu-latest
39
+ strategy:
40
+ fail-fast: false
41
+ matrix:
42
+ python-version: ["3.11", "3.12", "3.13"]
43
+ steps:
44
+ - uses: actions/checkout@v5
45
+ - uses: actions/setup-python@v6
46
+ with:
47
+ python-version: ${{ matrix.python-version }}
48
+ cache: pip
49
+ - run: pip install -e ".[dev]"
50
+ - name: pytest
51
+ run: pytest -q
52
+
53
+ audit:
54
+ runs-on: ubuntu-latest
55
+ steps:
56
+ - uses: actions/checkout@v5
57
+ - uses: actions/setup-python@v6
58
+ with:
59
+ python-version: "3.13"
60
+ cache: pip
61
+ - run: pip install -e .
62
+ - name: bpkit audit
63
+ shell: bash
64
+ run: |
65
+ set -o pipefail
66
+ {
67
+ echo '### bpkit audit'
68
+ echo '```'
69
+ } >> "$GITHUB_STEP_SUMMARY"
70
+ status=0
71
+ bpkit audit $AUDIT_TARGETS $AUDIT_ARGS | tee -a "$GITHUB_STEP_SUMMARY" || status=$?
72
+ echo '```' >> "$GITHUB_STEP_SUMMARY"
73
+ exit $status
74
+ - name: JSON report
75
+ if: always()
76
+ run: bpkit audit $AUDIT_TARGETS $AUDIT_ARGS --json > audit.json || true
77
+ - uses: actions/upload-artifact@v4
78
+ if: always()
79
+ with:
80
+ name: bpkit-audit
81
+ path: audit.json
@@ -0,0 +1,61 @@
1
+ name: Docs
2
+
3
+ # Build the pdoc site (guide + API reference) and publish it to GitHub Pages.
4
+ # Pull requests build the site and check the guide's examples, but only main deploys.
5
+
6
+ on:
7
+ push:
8
+ branches: [main]
9
+ pull_request:
10
+ workflow_dispatch:
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ concurrency:
16
+ group: pages-${{ github.ref }}
17
+ cancel-in-progress: true
18
+
19
+ jobs:
20
+ build:
21
+ runs-on: ubuntu-latest
22
+ steps:
23
+ - uses: actions/checkout@v5
24
+
25
+ - uses: actions/setup-python@v6
26
+ with:
27
+ python-version: "3.12"
28
+ cache: pip
29
+
30
+ - name: Install
31
+ run: pip install -e ".[dev]"
32
+
33
+ - name: Check the guide's examples still run
34
+ run: pytest -q tests/test_docs.py
35
+
36
+ # docs/guide holds one stub module per guide page (slices of docs/guide.md).
37
+ - name: Build site
38
+ run: |
39
+ pdoc bpkit ./docs/guide \
40
+ --output-directory site \
41
+ --edit-url bpkit=https://github.com/${{ github.repository }}/blob/${{ github.sha }}/src/bpkit/ \
42
+ --footer-text "bpkit ${{ github.sha }}"
43
+ echo '<meta http-equiv="refresh" content="0; url=bpkit.html">' > site/index.html
44
+
45
+ - uses: actions/upload-pages-artifact@v4
46
+ with:
47
+ path: site
48
+
49
+ deploy:
50
+ if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
51
+ needs: build
52
+ runs-on: ubuntu-latest
53
+ permissions:
54
+ pages: write
55
+ id-token: write
56
+ environment:
57
+ name: github-pages
58
+ url: ${{ steps.deployment.outputs.page_url }}
59
+ steps:
60
+ - id: deployment
61
+ uses: actions/deploy-pages@v4
@@ -0,0 +1,47 @@
1
+ name: Publish
2
+
3
+ # Push a tag like v0.1.0 to build and upload to PyPI via trusted publishing (no API token).
4
+ # One-time setup: add a trusted publisher on pypi.org for this repo, workflow publish.yml,
5
+ # environment "pypi".
6
+
7
+ on:
8
+ push:
9
+ tags: ["v*"]
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ jobs:
15
+ build:
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v5
19
+ - uses: actions/setup-python@v6
20
+ with:
21
+ python-version: "3.13"
22
+ - name: Tag matches pyproject version
23
+ run: |
24
+ version=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
25
+ test "v$version" = "$GITHUB_REF_NAME" || { echo "::error::tag $GITHUB_REF_NAME != v$version"; exit 1; }
26
+ - run: pip install -e ".[dev]" build
27
+ - run: pytest -q
28
+ - run: python -m build
29
+ - uses: actions/upload-artifact@v4
30
+ with:
31
+ name: dist
32
+ path: dist/
33
+
34
+ publish:
35
+ needs: build
36
+ runs-on: ubuntu-latest
37
+ environment:
38
+ name: pypi
39
+ url: https://pypi.org/p/osbuild-bpkit
40
+ permissions:
41
+ id-token: write
42
+ steps:
43
+ - uses: actions/download-artifact@v4
44
+ with:
45
+ name: dist
46
+ path: dist/
47
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ docs/html/
@@ -0,0 +1,392 @@
1
+ Metadata-Version: 2.5
2
+ Name: osbuild-bpkit
3
+ Version: 0.2.0
4
+ Summary: Define osbuild/image-builder blueprints as Python classes with inheritance
5
+ License-Expression: Apache-2.0
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Topic :: System :: Installation/Setup
8
+ Classifier: Typing :: Typed
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: pydantic>=2.11
11
+ Requires-Dist: tomli-w>=1.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pdoc>=14; extra == 'dev'
14
+ Requires-Dist: pytest>=8; extra == 'dev'
15
+ Requires-Dist: ruff>=0.6; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # bpkit
19
+
20
+ Define [osbuild / image-builder](https://osbuild.org) **blueprints as Python
21
+ classes** and render them to blueprint TOML.
22
+
23
+ Blueprints have no include/extends mechanism, so teams copy-paste a base
24
+ blueprint across variants. With bpkit a base class holds the common config,
25
+ mixins add concerns (hardening, monitoring), and subclasses add or override,
26
+ just like normal Python inheritance. The output is plain blueprint TOML for
27
+ `image-builder build` / `composer-cli`, so bpkit only depends on the stable
28
+ blueprint format.
29
+
30
+ ```python
31
+ from bpkit import Blueprint, Services, User, Filesystem, Overlay, Remove, Replace
32
+
33
+
34
+ class Base(Blueprint, abstract=True):
35
+ """Common base for all our images."""
36
+
37
+ packages = ["vim", "tmux"]
38
+ services = Services(enabled=["sshd"])
39
+ timezone = "Australia/Sydney"
40
+ users = [User(name="ops", groups=["wheel"], key="ssh-ed25519 AAAA...")]
41
+ overlay = Overlay("overlays/base") # config files from a directory tree
42
+
43
+
44
+ class Hardened(Blueprint, abstract=True): # mixin
45
+ services = Services(enabled=["firewalld"], masked=["cups"])
46
+ kernel_append = ["audit=1"]
47
+
48
+
49
+ class WebServer(Base, Hardened):
50
+ packages = ["nginx"] # ADDS → vim, tmux, nginx
51
+ timezone = "UTC" # scalar → OVERRIDES
52
+ filesystem = [Filesystem("/var", "10 GiB")]
53
+
54
+
55
+ class StagingWeb(WebServer):
56
+ packages = ["htop", Remove("tmux")] # add htop, drop inherited tmux
57
+
58
+
59
+ class Minimal(WebServer):
60
+ packages = Replace(["nginx"]) # discard everything inherited
61
+
62
+
63
+ bp = StagingWeb(hostname="staging-web01") # instance kwargs override too
64
+ print(bp.to_toml())
65
+ bp.write("staging-web.toml")
66
+ ```
67
+
68
+ See [`examples/fleet.py`](examples/fleet.py) for a fuller example and
69
+ [`tests/golden/`](tests/golden) for what it renders to.
70
+
71
+ # Usage
72
+
73
+ ## Install
74
+
75
+ ```bash
76
+ pip install . # Python ≥ 3.11; deps: pydantic>=2.11, tomli-w
77
+ ```
78
+
79
+ ## How resolution works
80
+
81
+ Each class is resolved when it is defined. bpkit walks `reversed(cls.__mro__)`
82
+ and, for every `Blueprint` subclass, folds in **only the attributes declared in
83
+ that class's own body**. Mixins and diamond hierarchies therefore behave like
84
+ normal Python: each class applies once, and for scalars the leftmost base wins,
85
+ just as with attribute lookup. Errors (a bad `Remove`, an invalid path, a
86
+ misspelt field) are raised on import, not at render time.
87
+
88
+ Instances get a deep copy of the resolved values, so mutating one never leaks
89
+ into the class or its siblings.
90
+
91
+ ### Merge rules
92
+
93
+ | Field kind | Fields | Rule |
94
+ |---|---|---|
95
+ | Scalars | `distro`, `architecture`, `hostname`, `timezone`, `fips`, `partitioning_mode`, `installation_device` | Child overrides |
96
+ | Keyed lists | `packages`, `modules`, `groups`, `enabled_modules` (by `name`); `users`, `user_groups` (by `name`); `sshkeys` (by `user`); `filesystem` (by `mountpoint`); `files`, `directories` (by `path`); `repositories` (by `id`); `containers` (by `source`) | Union by key; a child entry replaces the parent entry with the same key in place; first-seen order |
97
+ | String sets | `kernel_append`, `ntpservers` | Ordered set union |
98
+ | Structs | `services`, `firewall`, `locale`, `kernel`, `installer` | Field by field: string lists union, scalars override, nested structs recurse |
99
+ | Passthrough | `extra_customizations` | Deep merge (dicts recurse, everything else replaces) |
100
+ | Identity | `name`, `description`, `version` | **Not inherited.** `name` defaults to the kebab-cased class name (`StagingWeb` → `staging-web`), `description` to the first line of the class's own docstring, `version` to `"0.0.1"` |
101
+
102
+ `kernel_append` is a list in Python and a space-joined string in TOML
103
+ (`customizations.kernel.append`). `timezone` and `ntpservers` both render into
104
+ `customizations.timezone`.
105
+
106
+ **Service conflicts:** if a child enables a service that a parent disabled or
107
+ masked (or the other way round), the child's intent wins and the service is
108
+ moved. It is never listed twice. The same applies to `firewall.services` and
109
+ `installer.modules`. Listing a service in two lists in one declaration is an
110
+ error.
111
+
112
+ ### Markers
113
+
114
+ | Marker | Example | Effect |
115
+ |---|---|---|
116
+ | `Remove(key, ...)` | `packages = ["htop", Remove("tmux")]` | Drops inherited entries by key. Removing a key that isn't inherited **raises**, which catches typos. Also works in string sets and inside structs (`Services(masked=[Remove("cups")])`). |
117
+ | `Replace(value)` | `packages = Replace(["nginx"])` | Discards everything inherited for that field (or struct sub-field: `Services(enabled=Replace([...]))`). |
118
+ | `Unset` | `timezone = Unset` | Removes an inherited value so it is omitted from the output. |
119
+
120
+ Markers are always resolved away; none reach `to_dict()`. An abstract mixin
121
+ may `Remove` something it doesn't inherit itself. The check runs when a
122
+ concrete class pulls the mixin in.
123
+
124
+ ### Shorthands
125
+
126
+ - `"vim"` → `Package("vim", "*")`; `Package("vim", "9.1*")`
127
+ - `"nodejs:20"` in `enabled_modules` → `EnabledModule("nodejs", "20")`
128
+ - `"Development Tools"` in `groups`; `"quay.io/x:1"` in `containers`
129
+ - `Filesystem("/var", "10 GiB")`: size is a string with units (`kB`, `KiB`, `MB`, `MiB`, `GB`, `GiB`, `TB`, `TiB`, the same as image-builder) or int bytes; the mountpoint must be absolute
130
+ - `File(path, data, user, group, mode)`: mode as `"0644"` or `0o644`
131
+ - Plain dicts are accepted anywhere a model is
132
+
133
+ ## Overlay import
134
+
135
+ Point a blueprint at a directory laid out like the target filesystem, and
136
+ bpkit turns it into `files` and `directories` customizations:
137
+
138
+ ```
139
+ overlays/web/
140
+ etc/nginx/conf.d/site.conf
141
+ etc/motd.d/10-banner
142
+ usr/local/bin/healthcheck # 0755 on disk → mode = "0755"
143
+ var/www/html/static/.gitkeep # marks an empty directory
144
+ ```
145
+
146
+ ```python
147
+ class WebServer(Base):
148
+ overlay = Overlay("overlays/web") # relative to this .py file
149
+ # or: overlay = ["overlays/web", Overlay("nginx", prefix="/etc/nginx")]
150
+ ```
151
+
152
+ - Every regular file becomes a `File` with inline UTF-8 contents. Its mode is
153
+ taken from disk unless you pass `file_mode=` or `preserve_mode=False`.
154
+ - Every leaf directory becomes a `Directory` with `ensure_parents = true`, so
155
+ missing parents are created. No mode, user or group is set unless you pass
156
+ `dir_mode=` / `user=` / `group=`. image-builder then creates the directory
157
+ with `exist_ok`, so directories the distro already ships (`/etc/nginx`) are
158
+ fine. Directories that always exist (`/etc`, `/usr/local/bin`, …) are skipped.
159
+ Use `directories="all"` or `"none"` to change this.
160
+ - Overlay entries merge by path like any other entry. A child's overlay file
161
+ replaces the parent's (the overlayfs mental model), explicit `files =
162
+ [...]` in the same class win over its overlay, and
163
+ `files = [Remove("/etc/motd")]` drops one.
164
+ - Drop an inherited overlay like removing a layer from an overlayfs stack:
165
+ whatever it was covering (a lower overlay's file, or an explicit `File`)
166
+ comes back.
167
+
168
+ ```python
169
+ class NoDebug(WebDebug):
170
+ overlay = [Remove("overlays/debug")] # just that one
171
+ class Bare(WebDebug):
172
+ overlay = Unset # all inherited overlays
173
+ class Swap(WebDebug):
174
+ overlay = Replace(Overlay("overlays/other")) # drop all, add this
175
+ ```
176
+
177
+ `Remove` takes the overlay's path. It is resolved against the removing
178
+ class's module first, then matched as a unique trailing path
179
+ (`"overlays/debug"`). Removing an overlay that isn't inherited raises.
180
+ - The overlay is read when the class is defined, and these problems are
181
+ reported then: symlinks, non-UTF-8 files, and paths image-builder's default
182
+ policy refuses (`/usr` outside `/usr/local`, `/boot`, `/etc/passwd`, …).
183
+ `check_policy=False` turns the path check off, for example for ostree image
184
+ types with different rules.
185
+ - Default excludes: `.gitkeep`, `.keep`, `.git`, `.DS_Store`, `*.swp`, `*~`,
186
+ `*.orig`, `*.rej`. Pass `exclude=[...]` to change them.
187
+
188
+ `bpkit overlay DIR [--prefix /etc/app]` prints the generated TOML without a
189
+ blueprint class.
190
+
191
+ ## Hooks and overrides
192
+
193
+ ```python
194
+ class Monitored(Blueprint, abstract=True):
195
+ def customize(self) -> None:
196
+ super().customize() # cooperative chaining
197
+ self.packages.add("node-exporter")
198
+ self.services.enable("node-exporter") # moves it out of disabled/masked
199
+
200
+
201
+ class App(Monitored, WebServer):
202
+ def customize(self) -> None:
203
+ super().customize()
204
+ if os.environ.get("DEBUG"):
205
+ self.packages.add("gdb")
206
+ self.packages.remove("tmux") # KeyError if missing
207
+ self.hostname = f"app-{self.version}" # assignment replaces the value
208
+ ```
209
+
210
+ Constructor kwargs follow the same merge rules as a subclass:
211
+ `WebServer(hostname="web01", packages=["curl"])`. Then `customize()` runs on
212
+ the instance.
213
+
214
+ Helpers: keyed lists (`self.packages`, `self.users`, `self.files`, …) have
215
+ `add`, `remove`, `discard`, `get`, `in` and lookup by key (`self.users["ops"]`).
216
+ String sets have `add`, `remove` and `discard`. `Services` has
217
+ `enable`/`disable`/`mask`/`reset`, `Firewall` has `open_port`/`close_port`,
218
+ and `FirewallServices` has `enable`/`disable`.
219
+
220
+ ## Output
221
+
222
+ - `to_dict()`: a plain dict in blueprint shape, using TOML names
223
+ (`tls_verify` → `tls-verify`, `users` → `customizations.user`,
224
+ `user_groups` → `customizations.group`). Empty and unset fields are omitted.
225
+ - `to_toml()` (via `tomli-w`), `write(path)`. Key and list order are stable,
226
+ so diffs in git stay clean.
227
+ - `explain(field=None)` shows which class contributed each value. It works on
228
+ a class or an instance, and instances also report `<kwargs>` and
229
+ `customize()`:
230
+
231
+ ```
232
+ $ bpkit explain examples/fleet.py:StagingWeb packages
233
+ packages:
234
+ audit <- Hardened
235
+ vim-enhanced <- Base
236
+ tmux <- Base
237
+ chrony <- Base
238
+ nginx <- WebServer
239
+ htop <- StagingWeb
240
+ strace <- StagingWeb
241
+ ```
242
+
243
+ (`aide` is missing because `StagingWeb` declares `Remove("aide")`.)
244
+
245
+ - `extra_customizations = {"openscap": {...}}` passes through anything not
246
+ modelled yet (openscap, ignition, fdo, disk, …). It is deep-merged into
247
+ `[customizations]`.
248
+
249
+ ## CLI
250
+
251
+ Targets are `module.path:Class` or `path/to/file.py:Class`.
252
+
253
+ ```bash
254
+ bpkit render examples/fleet.py:StagingWeb [-o staging.toml] # stdout by default
255
+ bpkit render examples/fleet.py -o out/ # every concrete class → out/<name>.toml
256
+ bpkit list examples/fleet.py # classes, bases, abstract or not
257
+ bpkit diff examples/fleet.py:WebServer examples/fleet.py:MinimalWeb # exit 1 if different
258
+ bpkit explain examples/fleet.py:StagingWeb [field]
259
+ bpkit overlay overlays/web [--prefix /] [--directories leaves|all|none]
260
+ ```
261
+
262
+ Mark bases and mixins `abstract=True` so bulk render skips them. Bulk render
263
+ only picks up classes **defined** in the module, not ones it imports.
264
+
265
+ ## Auditing
266
+
267
+ ```bash
268
+ bpkit audit fleet.py [--strict] [--json] [--ignore BP046] [--hosted]
269
+ bpkit audit fleet.py --advisories --depsolve composer # or --packages rpm-qa.txt
270
+ ```
271
+
272
+ The offline checks flag:
273
+
274
+ - values image-builder would reject
275
+ - contradictory settings (SSH keys with `sshd` masked, firewall rules with
276
+ `firewalld` off)
277
+ - risky settings: plain-text passwords, world-writable or setuid modes,
278
+ secrets in files, HTTP or unsigned repositories, kernel args like
279
+ `selinux=0`, legacy daemons
280
+ - overlay usage: empty overlays, shadowed or replaced files
281
+
282
+ `--advisories` looks up known advisories for exact package versions on
283
+ OSV.dev (RHEL, AlmaLinux and Rocky Linux). Every finding has a stable code
284
+ (the table is in the `bpkit.audit` docs), names the class and overlay that
285
+ set the value, and can be silenced per class with
286
+ `AUDIT_IGNORE = ["BP011"]`.
287
+
288
+ ## SBOMs
289
+
290
+ ```bash
291
+ bpkit sbom fleet.py:WebServer -t qcow2 [--format json|xml] [-o web.cdx.json]
292
+ bpkit sbom fleet.py:WebServer --backend composer
293
+ ```
294
+
295
+ Writes a CycloneDX 1.6 SBOM (JSON or XML) of every package the image
296
+ installs. Packages come from an `image-builder manifest` depsolve (no build,
297
+ no root) with purls and SHA-256 digests, or from an osbuild-composer depsolve.
298
+
299
+ ## Importing and building
300
+
301
+ ```bash
302
+ bpkit import web.toml db.toml --base Fleet -o fleet.py [--overlay-dir overlays]
303
+ bpkit import --from-composer web-server -o web.py
304
+
305
+ bpkit build fleet.py:WebServer -t qcow2 # local image-builder
306
+ bpkit build fleet.py:WebServer -t qcow2 --backend composer # osbuild-composer (Weldr socket)
307
+ bpkit build fleet.py:WebServer -t guest-image --backend hosted # console.redhat.com
308
+ bpkit push fleet.py:WebServer [--backend hosted --type guest-image]
309
+ bpkit status <id> [--backend hosted]
310
+
311
+ bpkit build fleet.py:WebServer -t qcow2 --repo http://mirror.lan/rhel9/baseos/ # mirror only
312
+ bpkit build fleet.py:WebServer -t qcow2 --extra-repo file:///srv/repos/internal/
313
+ ```
314
+
315
+ - **Local repos and mirrors:** `--repo URL` (repeatable) replaces the distro's
316
+ standard repositories for the build and `--extra-repo` adds one. Set
317
+ defaults for a hierarchy with `BUILD_REPOS = [...]` /
318
+ `BUILD_EXTRA_REPOS = [...]` on a class. They are only used to fetch
319
+ packages and aren't written into the image. They apply to the local
320
+ backend; `--data-dir` passes image-builder custom `repositories/*.json`.
321
+
322
+ - **Import:** you get one class per blueprint, and `--base` factors the
323
+ shared settings into an abstract class. The output renders back to the
324
+ same blueprints; only list order can change.
325
+ - **Hosted credentials:** `RH_OFFLINE_TOKEN`, or `RH_CLIENT_ID` and
326
+ `RH_CLIENT_SECRET` for a service account.
327
+ - **Hosted conversion:** blueprints are converted to the service's JSON
328
+ format. Settings it can't express are reported as errors unless you pass
329
+ `--lossy`.
330
+ - **No extra dependencies:** the clients only use the standard library.
331
+ They are tested against fake servers, not the live services.
332
+
333
+ ## Design notes
334
+
335
+ - Field names and types were checked against `osbuild/blueprint`
336
+ (`pkg/blueprint`, which `osbuild/images` now imports). The path policies
337
+ for overlays come from `osbuild/images` `pkg/policies`.
338
+ - Lowercase public class attributes that aren't blueprint fields are an error
339
+ (`pakages = [...]` → "did you mean 'packages'?"). Use `UPPER_CASE` or
340
+ `_private` names for your own attributes.
341
+ - `name`, `description` and `version` are per class, not inherited.
342
+
343
+ ## Docs
344
+
345
+ The usage guide is [`docs/guide.md`](docs/guide.md). Published docs, with the
346
+ API reference: <https://drcoffee.github.io/bpkit/>.
347
+
348
+ # Contributing
349
+
350
+ ## Setup
351
+
352
+ ```bash
353
+ pip install -e ".[dev]"
354
+ pytest && ruff check .
355
+ ```
356
+
357
+ ## Tests
358
+
359
+ `pytest` runs unit, round-trip (`tomllib`), golden-file and CLI tests.
360
+ Regenerate the golden files with `BPKIT_UPDATE_GOLDEN=1 pytest tests/test_golden.py`.
361
+ If `image-builder` is on `PATH`, an integration test also runs
362
+ `image-builder manifest qcow2 --blueprint …` on each example; set
363
+ `BPKIT_TEST_DISTRO` to choose the distro (default `fedora-42`).
364
+
365
+ ## Building the docs
366
+
367
+ pdoc renders the intro of `docs/guide.md` as the landing page, each section
368
+ between `<!-- page: … -->` markers as its own page (via the stub modules in
369
+ `docs/guide/`), and one API page per module:
370
+
371
+ ```bash
372
+ pdoc bpkit ./docs/guide # live preview in the browser
373
+ pdoc -o docs/html bpkit ./docs/guide # static HTML
374
+ ```
375
+
376
+ To add a guide page, put a `<!-- page: name -->` marker in `docs/guide.md`,
377
+ copy a stub in `docs/guide/` (fixing its `start-after`/`end-before`), and link
378
+ it from the list in `src/bpkit/__init__.py`.
379
+
380
+ The [Docs workflow](.github/workflows/docs.yml) rebuilds and deploys the site
381
+ on every push to `main`. Pull requests build the site and run the guide's
382
+ examples without deploying.
383
+
384
+ `tests/test_docs.py` runs every Python block in the guide and checks the
385
+ `explain()` sample against real output, so the guide can't drift from the code.
386
+
387
+ ## CI
388
+
389
+ [`.github/workflows/ci.yml`](.github/workflows/ci.yml) runs ruff, the tests on
390
+ Python 3.11 to 3.13, and `bpkit audit` on `AUDIT_TARGETS` for every push to
391
+ `main` and every pull request. The audit report goes to the job summary and a
392
+ JSON artifact. Set `AUDIT_ARGS: --strict` to fail on warnings too.