Skip to content

Commit 49e23e8

Browse files
committed
feat: add test suite and CI for bbclass Quadlet generation
Add comprehensive pytest test suite (356 tests) covering all bbclass Quadlet generators with a BitBake datastore mock framework. Tests cover container, pod, network, localconf, and manifest bbclass files including privileged mode, .network suffix resolution, network aliases, disabled/enabled routing, validation, and full integration scenarios. Add GitHub Actions CI workflow running on Python 3.9/3.11/3.12. Also fixes: - container-quadlet.bbclass: add .network suffix for Quadlet-defined networks - container-manifest.bbclass: add PodmanArgs=--privileged for privileged mode - container-manifest.bbclass: add .network suffix for containers and pods
1 parent a24c9d3 commit 49e23e8

11 files changed

Lines changed: 5191 additions & 4 deletions

.github/workflows/test.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
branches: [main, scarthgap, styhead]
6+
pull_request:
7+
branches: [main, scarthgap, styhead]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
python-version: ["3.9", "3.11", "3.12"]
15+
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- name: Set up Python ${{ matrix.python-version }}
20+
uses: actions/setup-python@v5
21+
with:
22+
python-version: ${{ matrix.python-version }}
23+
24+
- name: Install dependencies
25+
run: pip install pytest
26+
27+
- name: Run tests
28+
run: pytest tests/ -v --tb=short

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
__pycache__/
2+
*.pyc
3+
.pytest_cache/

classes/container-localconf.bbclass

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,7 @@ python do_generate_quadlets() {
797797
privileged = get_container_var(d, container_name, 'PRIVILEGED')
798798
if privileged == '1':
799799
lines.append("SecurityLabelDisable=true")
800+
lines.append("PodmanArgs=--privileged")
800801

801802
security_opts = get_container_var(d, container_name, 'SECURITY_OPTS')
802803
if security_opts:

classes/container-manifest.bbclass

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -950,7 +950,13 @@ python do_generate_quadlets() {
950950
# Network mode
951951
network = container.get('network', '')
952952
if network:
953-
lines.append("Network=" + network)
953+
# If network matches a Quadlet-defined network, use the .network
954+
# suffix so Quadlet creates proper dependency ordering.
955+
defined_networks = get_network_list_from_manifest(d)
956+
if network in defined_networks:
957+
lines.append("Network=" + network + ".network")
958+
else:
959+
lines.append("Network=" + network)
954960

955961
# User
956962
user = container.get('user', '')
@@ -975,6 +981,7 @@ python do_generate_quadlets() {
975981
# Security options
976982
if container.get('privileged'):
977983
lines.append("SecurityLabelDisable=true")
984+
lines.append("PodmanArgs=--privileged")
978985

979986
security_opts = container.get('security_opts', [])
980987
if security_opts:
@@ -1155,7 +1162,11 @@ python do_generate_pods() {
11551162
# Network mode
11561163
network = pod.get('network', '')
11571164
if network:
1158-
lines.append("Network=" + network)
1165+
defined_networks = get_network_list_from_manifest(d)
1166+
if network in defined_networks:
1167+
lines.append("Network=" + network + ".network")
1168+
else:
1169+
lines.append("Network=" + network)
11591170

11601171
# Volume mounts (shared by all containers in pod)
11611172
volumes = pod.get('volumes', [])

classes/container-quadlet.bbclass

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,10 +210,14 @@ python do_generate_quadlet() {
210210
for device in devices.split():
211211
lines.append("AddDevice=" + device)
212212

213-
# Network mode
213+
# Network mode - append .network suffix for Quadlet-defined networks
214214
network = d.getVar('CONTAINER_NETWORK')
215215
if network:
216-
lines.append("Network=" + network)
216+
networks_list = (d.getVar('NETWORKS') or '').split()
217+
if network in networks_list:
218+
lines.append("Network=" + network + ".network")
219+
else:
220+
lines.append("Network=" + network)
217221

218222
# User
219223
user = d.getVar('CONTAINER_USER')
@@ -236,6 +240,7 @@ python do_generate_quadlet() {
236240
privileged = d.getVar('CONTAINER_PRIVILEGED')
237241
if privileged == '1':
238242
lines.append("SecurityLabelDisable=true")
243+
lines.append("PodmanArgs=--privileged")
239244

240245
security_opts = d.getVar('CONTAINER_SECURITY_OPTS')
241246
if security_opts:

tests/conftest.py

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
"""
2+
Test fixtures and BitBake datastore mock for meta-container-deploy bbclass testing.
3+
4+
The bbclass files contain Python functions that depend on BitBake's datastore (d)
5+
and bb module. This module provides mocks that allow testing the generation logic
6+
without a full BitBake environment.
7+
"""
8+
9+
import os
10+
import re
11+
import sys
12+
import types
13+
import tempfile
14+
15+
import pytest
16+
17+
18+
class MockDataStore:
19+
"""Mock BitBake DataStore that supports getVar/setVar."""
20+
21+
def __init__(self):
22+
self._vars = {}
23+
24+
def getVar(self, name, expand=True):
25+
return self._vars.get(name, None)
26+
27+
def setVar(self, name, value):
28+
self._vars[name] = value
29+
30+
def appendVar(self, name, value):
31+
current = self._vars.get(name, '')
32+
self._vars[name] = current + value
33+
34+
def delVar(self, name):
35+
self._vars.pop(name, None)
36+
37+
38+
class MockBB:
39+
"""Mock BitBake bb module."""
40+
41+
def __init__(self):
42+
self.notes = []
43+
self.warnings = []
44+
self.fatals = []
45+
46+
def note(self, msg):
47+
self.notes.append(msg)
48+
49+
def warn(self, msg):
50+
self.warnings.append(msg)
51+
52+
def fatal(self, msg):
53+
self.fatals.append(msg)
54+
raise BBFatalError(msg)
55+
56+
57+
class BBFatalError(Exception):
58+
"""Raised when bb.fatal() is called."""
59+
pass
60+
61+
62+
def extract_python_functions(bbclass_path):
63+
"""Extract Python function bodies from a bbclass file.
64+
65+
Handles two types:
66+
1. Standard Python defs: 'def func_name(...):'
67+
2. BitBake Python tasks: 'python task_name() {'
68+
"""
69+
with open(bbclass_path, 'r') as f:
70+
content = f.read()
71+
72+
functions = {}
73+
74+
# Extract standard Python defs (they're at module level in bbclass files)
75+
# These are regular Python function definitions
76+
lines = content.split('\n')
77+
i = 0
78+
while i < len(lines):
79+
line = lines[i]
80+
# Match 'def func_name(...):'
81+
match = re.match(r'^def\s+(\w+)\s*\(', line)
82+
if match:
83+
func_name = match.group(1)
84+
func_lines = [line]
85+
i += 1
86+
while i < len(lines):
87+
# Continue until we hit a non-indented, non-empty line
88+
if lines[i] and not lines[i][0].isspace() and not lines[i].startswith('#'):
89+
break
90+
func_lines.append(lines[i])
91+
i += 1
92+
functions[func_name] = '\n'.join(func_lines)
93+
continue
94+
95+
# Match 'python task_name() {'
96+
match = re.match(r'^python\s+(\w+)\s*\(\)\s*\{', line)
97+
if match:
98+
func_name = match.group(1)
99+
task_lines = []
100+
i += 1
101+
brace_depth = 1
102+
while i < len(lines) and brace_depth > 0:
103+
# Count braces in the line (but not in strings)
104+
for ch in lines[i]:
105+
if ch == '{':
106+
brace_depth += 1
107+
elif ch == '}':
108+
brace_depth -= 1
109+
if brace_depth == 0:
110+
break
111+
if brace_depth > 0:
112+
task_lines.append(lines[i])
113+
i += 1
114+
# Wrap as a function that takes d as parameter
115+
body = '\n'.join(task_lines)
116+
functions[func_name] = f"def {func_name}(d, bb):\n" + \
117+
'\n'.join(' ' + l if l.strip() else '' for l in task_lines)
118+
continue
119+
120+
i += 1
121+
122+
return functions
123+
124+
125+
def load_bbclass(bbclass_path, mock_bb=None):
126+
"""Load a bbclass file and return a namespace with its Python functions.
127+
128+
Returns a module-like namespace where all functions are available.
129+
"""
130+
if mock_bb is None:
131+
mock_bb = MockBB()
132+
133+
functions = extract_python_functions(bbclass_path)
134+
135+
# Build a combined source with all standard defs first
136+
namespace = {
137+
'bb': mock_bb,
138+
'os': os,
139+
'__builtins__': __builtins__,
140+
}
141+
142+
# First, compile and exec all standard Python defs (helpers)
143+
helper_source = []
144+
task_source = {}
145+
for name, source in functions.items():
146+
if source.startswith('def ') and not source.startswith(f'def {name}(d, bb)'):
147+
helper_source.append(source)
148+
else:
149+
task_source[name] = source
150+
151+
if helper_source:
152+
combined = '\n\n'.join(helper_source)
153+
exec(compile(combined, bbclass_path, 'exec'), namespace)
154+
155+
# Then compile task functions (they may reference helpers)
156+
for name, source in task_source.items():
157+
exec(compile(source, bbclass_path, 'exec'), namespace)
158+
159+
return namespace
160+
161+
162+
@pytest.fixture
163+
def mock_bb():
164+
"""Provide a fresh MockBB instance."""
165+
return MockBB()
166+
167+
168+
@pytest.fixture
169+
def datastore():
170+
"""Provide a fresh MockDataStore instance."""
171+
return MockDataStore()
172+
173+
174+
@pytest.fixture
175+
def workdir():
176+
"""Provide a temporary working directory."""
177+
with tempfile.TemporaryDirectory() as tmpdir:
178+
yield tmpdir
179+
180+
181+
@pytest.fixture
182+
def classes_dir():
183+
"""Return the path to the bbclass files."""
184+
return os.path.join(os.path.dirname(os.path.dirname(__file__)), 'classes')
185+
186+
187+
def parse_quadlet(content):
188+
"""Parse a Quadlet file into sections with their key-value pairs.
189+
190+
Returns a dict like:
191+
{
192+
'Unit': {'Description': '...', 'After': ['...', '...']},
193+
'Container': {'Image': '...', 'PublishPort': ['8080:80']},
194+
'Service': {'Restart': 'always'},
195+
'Install': {'WantedBy': 'multi-user.target'},
196+
}
197+
198+
Multi-valued keys are stored as lists.
199+
"""
200+
sections = {}
201+
current_section = None
202+
203+
for line in content.split('\n'):
204+
line = line.strip()
205+
if not line or line.startswith('#'):
206+
continue
207+
if line.startswith('[') and line.endswith(']'):
208+
current_section = line[1:-1]
209+
sections[current_section] = {}
210+
continue
211+
if current_section and '=' in line:
212+
key, value = line.split('=', 1)
213+
if key in sections[current_section]:
214+
existing = sections[current_section][key]
215+
if isinstance(existing, list):
216+
existing.append(value)
217+
else:
218+
sections[current_section][key] = [existing, value]
219+
else:
220+
sections[current_section][key] = value
221+
222+
return sections

0 commit comments

Comments
 (0)