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.
- osbuild_bpkit-0.2.0/.github/workflows/ci.yml +81 -0
- osbuild_bpkit-0.2.0/.github/workflows/docs.yml +61 -0
- osbuild_bpkit-0.2.0/.github/workflows/publish.yml +47 -0
- osbuild_bpkit-0.2.0/.gitignore +9 -0
- osbuild_bpkit-0.2.0/PKG-INFO +392 -0
- osbuild_bpkit-0.2.0/README.md +375 -0
- osbuild_bpkit-0.2.0/docs/guide/__init__.py +6 -0
- osbuild_bpkit-0.2.0/docs/guide/audit.py +5 -0
- osbuild_bpkit-0.2.0/docs/guide/basics.py +5 -0
- osbuild_bpkit-0.2.0/docs/guide/cli.py +5 -0
- osbuild_bpkit-0.2.0/docs/guide/gotchas.py +4 -0
- osbuild_bpkit-0.2.0/docs/guide/instances.py +5 -0
- osbuild_bpkit-0.2.0/docs/guide/overlays.py +5 -0
- osbuild_bpkit-0.2.0/docs/guide.md +397 -0
- osbuild_bpkit-0.2.0/examples/fleet.py +97 -0
- osbuild_bpkit-0.2.0/examples/overlays/base/etc/motd.d/10-banner +1 -0
- osbuild_bpkit-0.2.0/examples/overlays/base/etc/ssh/sshd_config.d/50-hardening.conf +2 -0
- osbuild_bpkit-0.2.0/examples/overlays/web/etc/motd.d/10-banner +1 -0
- osbuild_bpkit-0.2.0/examples/overlays/web/etc/nginx/conf.d/site.conf +4 -0
- osbuild_bpkit-0.2.0/examples/overlays/web/usr/local/bin/healthcheck +2 -0
- osbuild_bpkit-0.2.0/examples/overlays/web/var/www/html/static/.gitkeep +0 -0
- osbuild_bpkit-0.2.0/pyproject.toml +45 -0
- osbuild_bpkit-0.2.0/src/bpkit/__init__.py +51 -0
- osbuild_bpkit-0.2.0/src/bpkit/audit.py +1008 -0
- osbuild_bpkit-0.2.0/src/bpkit/blueprint.py +395 -0
- osbuild_bpkit-0.2.0/src/bpkit/build.py +206 -0
- osbuild_bpkit-0.2.0/src/bpkit/cli.py +605 -0
- osbuild_bpkit-0.2.0/src/bpkit/composer.py +280 -0
- osbuild_bpkit-0.2.0/src/bpkit/hosted.py +514 -0
- osbuild_bpkit-0.2.0/src/bpkit/importer.py +542 -0
- osbuild_bpkit-0.2.0/src/bpkit/markers.py +92 -0
- osbuild_bpkit-0.2.0/src/bpkit/merge.py +749 -0
- osbuild_bpkit-0.2.0/src/bpkit/models.py +650 -0
- osbuild_bpkit-0.2.0/src/bpkit/overlay.py +267 -0
- osbuild_bpkit-0.2.0/src/bpkit/py.typed +0 -0
- osbuild_bpkit-0.2.0/src/bpkit/render.py +111 -0
- osbuild_bpkit-0.2.0/src/bpkit/sbom.py +220 -0
- osbuild_bpkit-0.2.0/tests/conftest.py +403 -0
- osbuild_bpkit-0.2.0/tests/golden/minimal-web.toml +114 -0
- osbuild_bpkit-0.2.0/tests/golden/staging-web.toml +126 -0
- osbuild_bpkit-0.2.0/tests/golden/web-server.toml +122 -0
- osbuild_bpkit-0.2.0/tests/test_audit.py +374 -0
- osbuild_bpkit-0.2.0/tests/test_cli.py +95 -0
- osbuild_bpkit-0.2.0/tests/test_docs.py +50 -0
- osbuild_bpkit-0.2.0/tests/test_golden.py +61 -0
- osbuild_bpkit-0.2.0/tests/test_importer.py +157 -0
- osbuild_bpkit-0.2.0/tests/test_inheritance.py +433 -0
- osbuild_bpkit-0.2.0/tests/test_instances.py +157 -0
- osbuild_bpkit-0.2.0/tests/test_models.py +144 -0
- osbuild_bpkit-0.2.0/tests/test_overlay.py +300 -0
- osbuild_bpkit-0.2.0/tests/test_remote.py +376 -0
- osbuild_bpkit-0.2.0/tests/test_render.py +177 -0
- 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,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.
|