summaryrefslogtreecommitdiffstats
path: root/tests/test_containerd_runtime.py
blob: 470ac3ba198b274409a050f8c13044acc04277f7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
# SPDX-FileCopyrightText: Copyright (C) 2026 Bruce Ashfield
#
# SPDX-License-Identifier: MIT
"""
Containerd runtime tests — recipe checks, build, and boot verification.

The tests automatically build container-image-host with
CONTAINER_PROFILE=containerd before booting. No local.conf changes needed.

Run:
    # Static tests only
    pytest tests/test_containerd_runtime.py -v -m "not slow and not boot" --poky-dir /opt/bruce/poky

    # All tests
    pytest tests/test_containerd_runtime.py -v --poky-dir /opt/bruce/poky
"""

import os
import re
import subprocess
import tempfile
import time
import pytest
from pathlib import Path

try:
    import pexpect
    PEXPECT_AVAILABLE = True
except ImportError:
    PEXPECT_AVAILABLE = False


def _run_bitbake(build_dir, recipe, extra_vars=None, timeout=3600):
    """Run bitbake with optional variable overrides via -R conf file."""
    bb_cmd = "bitbake"
    conf_file = None
    if extra_vars:
        conf_file = tempfile.NamedTemporaryFile(
            mode='w', suffix='.conf', prefix='pytest-containerd-',
            dir=str(build_dir / "conf"), delete=False)
        for var, val in extra_vars.items():
            conf_file.write(f'{var} = "{val}"\n')
        conf_file.close()
        bb_cmd += f" -R {conf_file.name}"
    bb_cmd += f" {recipe}"
    poky_dir = build_dir.parent
    full_cmd = f"bash -c 'cd {poky_dir} && source oe-init-build-env {build_dir} >/dev/null 2>&1 && {bb_cmd}'"
    try:
        return subprocess.run(full_cmd, shell=True, cwd=build_dir,
                              timeout=timeout, capture_output=True, text=True)
    finally:
        if conf_file:
            os.unlink(conf_file.name)


# ============================================================================
# Fixtures
# ============================================================================

@pytest.fixture(scope="module")
def poky_dir(request):
    path = Path(request.config.getoption("--poky-dir"))
    if not path.exists():
        pytest.skip(f"Poky directory not found: {path}")
    return path


@pytest.fixture(scope="module")
def build_dir(request, poky_dir):
    bd = request.config.getoption("--build-dir")
    path = Path(bd) if bd else poky_dir / "build"
    if not path.exists():
        pytest.skip(f"Build directory not found: {path}")
    return path


@pytest.fixture(scope="module")
def meta_virt_dir(poky_dir):
    path = poky_dir / "meta-virtualization"
    if not path.exists():
        pytest.skip(f"meta-virtualization not found: {path}")
    return path


@pytest.fixture(scope="module")
def containerd_image(build_dir):
    """Build container-image-host with containerd profile."""
    result = _run_bitbake(
        build_dir, "container-image-host",
        extra_vars={"CONTAINER_PROFILE": "containerd"},
    )
    if result.returncode != 0:
        pytest.fail(f"Containerd image build failed: {result.stderr}")


@pytest.fixture(scope="module")
def containerd_session(request, poky_dir, build_dir, containerd_image):
    """Boot container-image-host with containerd and provide pexpect session."""
    if not PEXPECT_AVAILABLE:
        pytest.skip("pexpect not installed")

    machine = request.config.getoption("--machine", default="qemux86-64")
    timeout = int(request.config.getoption("--boot-timeout", default="120"))
    no_kvm = request.config.getoption("--no-kvm", default=False)

    kvm_opt = "" if no_kvm else "kvm"
    cmd = (
        f"bash -c 'cd {poky_dir} && "
        f"source oe-init-build-env {build_dir} >/dev/null 2>&1 && "
        f"runqemu {machine} container-image-host ext4 nographic slirp "
        f"{kvm_opt} qemuparams=\"-m 2048\"'"
    )

    child = pexpect.spawn(cmd, encoding='utf-8', timeout=timeout)
    child.logfile_read = open('/tmp/runqemu-containerd-test.log', 'w')

    try:
        index = child.expect([r'login:', r'root@', pexpect.TIMEOUT, pexpect.EOF],
                             timeout=timeout)
        if index == 0:
            child.sendline('root')
            child.expect([r'root@', r'#'], timeout=30)
        elif index >= 2:
            raise RuntimeError("Boot failed")

        child.sendline('export TERM=dumb')
        child.expect(r'root@[^:]+:[^#]+#', timeout=10)

        yield child

    except RuntimeError as e:
        pytest.skip(f"Failed to boot: {e}")
    finally:
        child.sendline('poweroff')
        try:
            child.expect(pexpect.EOF, timeout=30)
        except pexpect.TIMEOUT:
            child.terminate(force=True)


def run_cmd(child, cmd, timeout=60):
    """Run a command and return (output, returncode)."""
    marker = f"__MARKER_{time.monotonic_ns()}__"
    child.sendline(f"{cmd}; echo {marker} $?")
    child.expect(marker + r" (\d+)", timeout=timeout)
    raw = child.before
    raw = re.sub(r'\x1b\]3008;[^\x07\x1b]*(?:\x07|\x1b\\)', '', raw)
    raw = re.sub(r'\x1b\[[0-9;]*[A-Za-z]', '', raw)
    output = raw.strip()
    rc = int(child.match.group(1))
    child.expect(r'root@[^:]+:[^#]+#', timeout=10)
    return output, rc


# ============================================================================
# Tier 1: Static recipe assertions
# ============================================================================

class TestContainerdRecipeStatic:
    """Static checks on containerd recipe."""

    def test_recipe_exists(self, meta_virt_dir):
        path = meta_virt_dir / "recipes-containers" / "containerd" / "containerd_git.bb"
        assert path.exists()

    def test_provides_virtual_containerd(self, meta_virt_dir):
        content = (meta_virt_dir / "recipes-containers" / "containerd" / "containerd_git.bb").read_text()
        assert "virtual/containerd" in content or "virtual-containerd" in content

    def test_systemd_service(self, meta_virt_dir):
        content = (meta_virt_dir / "recipes-containers" / "containerd" / "containerd_git.bb").read_text()
        assert "containerd.service" in content

    def test_rdepends_container_runtime(self, meta_virt_dir):
        content = (meta_virt_dir / "recipes-containers" / "containerd" / "containerd_git.bb").read_text()
        assert "VIRTUAL-RUNTIME_container_runtime" in content

    def test_cni_networking(self, meta_virt_dir):
        content = (meta_virt_dir / "recipes-containers" / "containerd" / "containerd_git.bb").read_text()
        assert "cni_networking" in content

    def test_installs_ctr(self, meta_virt_dir):
        content = (meta_virt_dir / "recipes-containers" / "containerd" / "containerd_git.bb").read_text()
        assert "ctr" in content

    def test_installs_shim(self, meta_virt_dir):
        content = (meta_virt_dir / "recipes-containers" / "containerd" / "containerd_git.bb").read_text()
        assert "containerd-shim-runc-v2" in content


class TestContainerdProfileStatic:
    """Static checks on containerd profile configuration."""

    def test_profile_conf_exists(self, meta_virt_dir):
        path = meta_virt_dir / "conf" / "distro" / "include" / "container-host-containerd.conf"
        assert path.exists()

    def test_profile_sets_containerd(self, meta_virt_dir):
        content = (meta_virt_dir / "conf" / "distro" / "include" / "container-host-containerd.conf").read_text()
        assert 'CONTAINER_PROFILE = "containerd"' in content

    def test_profile_inc_exists(self, meta_virt_dir):
        path = meta_virt_dir / "conf" / "distro" / "include" / "meta-virt-container-containerd.inc"
        assert path.exists()

    def test_profile_engine_is_containerd(self, meta_virt_dir):
        content = (meta_virt_dir / "conf" / "distro" / "include" / "meta-virt-container-containerd.inc").read_text()
        assert "containerd" in content
        assert "VIRTUAL-RUNTIME_container_engine" in content

    def test_packagegroup_containerd(self, meta_virt_dir):
        content = (meta_virt_dir / "recipes-core" / "packagegroups" / "packagegroup-container.bb").read_text()
        assert "packagegroup-containerd" in content
        assert "nerdctl" in content


class TestNerdctlRecipeStatic:
    """Static checks on nerdctl recipe."""

    def test_recipe_exists(self, meta_virt_dir):
        recipes = list((meta_virt_dir / "recipes-containers" / "nerdctl").glob("nerdctl_*.bb"))
        assert len(recipes) >= 1, "nerdctl recipe not found"


class TestCriToolsRecipeStatic:
    """Static checks on cri-tools (crictl) recipe."""

    def test_recipe_exists(self, meta_virt_dir):
        recipes = list((meta_virt_dir / "recipes-containers" / "cri-tools").glob("cri-tools_*.bb"))
        assert len(recipes) >= 1, "cri-tools recipe not found"


class TestCriORecipeStatic:
    """Static checks on CRI-O recipe."""

    def test_recipe_exists(self, meta_virt_dir):
        path = meta_virt_dir / "recipes-containers" / "cri-o" / "cri-o_git.bb"
        assert path.exists()

    def test_requires_seccomp(self, meta_virt_dir):
        content = (meta_virt_dir / "recipes-containers" / "cri-o" / "cri-o_git.bb").read_text()
        assert "seccomp" in content
        assert "REQUIRED_DISTRO_FEATURES" in content

    def test_systemd_service(self, meta_virt_dir):
        content = (meta_virt_dir / "recipes-containers" / "cri-o" / "cri-o_git.bb").read_text()
        assert "crio.service" in content

    def test_rdepends_cni(self, meta_virt_dir):
        content = (meta_virt_dir / "recipes-containers" / "cri-o" / "cri-o_git.bb").read_text()
        assert "cni" in content

    def test_rdepends_conmon(self, meta_virt_dir):
        content = (meta_virt_dir / "recipes-containers" / "cri-o" / "cri-o_git.bb").read_text()
        assert "conmon" in content

    def test_rdepends_runtime(self, meta_virt_dir):
        content = (meta_virt_dir / "recipes-containers" / "cri-o" / "cri-o_git.bb").read_text()
        assert "VIRTUAL-RUNTIME_container_runtime" in content


# ============================================================================
# Tier 2: Build verification
# ============================================================================

@pytest.mark.slow
class TestContainerdBuild:
    """Build tests for containerd."""

    def test_containerd_builds(self, build_dir):
        result = _run_bitbake(build_dir, "containerd")
        assert result.returncode == 0, f"containerd build failed: {result.stderr}"

    def test_nerdctl_builds(self, build_dir):
        result = _run_bitbake(build_dir, "nerdctl")
        assert result.returncode == 0, f"nerdctl build failed: {result.stderr}"

    def test_containerd_image_builds(self, build_dir, containerd_image):
        """container-image-host with containerd profile builds (via fixture)."""
        pass


# ============================================================================
# Tier 3: Boot tests — containerd
# ============================================================================

@pytest.mark.slow
@pytest.mark.boot
class TestContainerdRuntime:
    """Boot tests for containerd on container-image-host."""

    def test_containerd_running(self, containerd_session):
        """containerd systemd service should be active."""
        output, rc = run_cmd(containerd_session, "systemctl is-active containerd")
        assert "active" in output, f"containerd not active: {output}"

    def test_ctr_available(self, containerd_session):
        """ctr command should be available."""
        output, rc = run_cmd(containerd_session, "which ctr")
        assert rc == 0, f"ctr not found: {output}"

    def test_nerdctl_available(self, containerd_session):
        """nerdctl command should be available."""
        output, rc = run_cmd(containerd_session, "which nerdctl")
        assert rc == 0, f"nerdctl not found: {output}"

    def test_containerd_version(self, containerd_session):
        """containerd should report its version."""
        output, rc = run_cmd(containerd_session, "containerd --version")
        assert rc == 0, f"containerd version failed: {output}"
        assert "containerd" in output

    def test_ctr_version(self, containerd_session):
        """ctr should report its version."""
        output, rc = run_cmd(containerd_session, "ctr version")
        assert rc == 0, f"ctr version failed: {output}"

    def test_ctr_namespaces(self, containerd_session):
        """ctr should list namespaces."""
        output, rc = run_cmd(containerd_session, "ctr namespaces list")
        assert rc == 0, f"ctr namespaces list failed: {output}"
        assert "NAME" in output, f"Unexpected namespaces output: {output}"

    def test_runtime_available(self, containerd_session):
        """Container runtime (runc/crun) should be installed."""
        output, rc = run_cmd(containerd_session,
                             "which crun 2>/dev/null || which runc 2>/dev/null")
        assert rc == 0, f"No container runtime found: {output}"

    def test_cni_plugins_installed(self, containerd_session):
        """CNI plugins should be installed."""
        output, rc = run_cmd(containerd_session, "ls /opt/cni/bin/bridge")
        assert rc == 0, f"CNI bridge plugin not found: {output}"

    def test_nerdctl_pull(self, containerd_session):
        """nerdctl should be able to pull an image."""
        output, rc = run_cmd(containerd_session,
                             "nerdctl pull docker.io/library/busybox:latest",
                             timeout=120)
        assert rc == 0, f"nerdctl pull failed: {output}"

    def test_nerdctl_run(self, containerd_session):
        """nerdctl should run a container."""
        output, rc = run_cmd(containerd_session,
                             'nerdctl run --rm busybox:latest echo CONTAINERD_WORKS',
                             timeout=60)
        assert rc == 0, f"nerdctl run failed: {output}"
        assert "CONTAINERD_WORKS" in output

    def test_nerdctl_images(self, containerd_session):
        """nerdctl images should show pulled images."""
        output, rc = run_cmd(containerd_session, "nerdctl images")
        assert rc == 0, f"nerdctl images failed: {output}"
        assert "busybox" in output

    def test_nerdctl_cleanup(self, containerd_session):
        """Clean up test image."""
        run_cmd(containerd_session, "nerdctl rmi busybox:latest", timeout=30)