summaryrefslogtreecommitdiffstats
path: root/tests/test_incus_runtime.py
blob: ea5eb1fcd18a59d35d52f4561ceabdf88917063c (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
# SPDX-FileCopyrightText: Copyright (C) 2026 Bruce Ashfield
#
# SPDX-License-Identifier: MIT
"""
Incus runtime tests - boot container-image-host with incus and verify
system container management.

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

Run:
    pytest tests/test_incus_runtime.py -v --poky-dir /opt/bruce/poky

Options:
    --boot-timeout      QEMU boot timeout (default: 120s)
    --no-kvm            Disable KVM acceleration
"""

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


pytestmark = [
    pytest.mark.skipif(not PEXPECT_AVAILABLE, reason="pexpect not installed"),
    pytest.mark.incus,
]


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-incus-',
            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)


@pytest.fixture(scope="module")
def incus_image(request):
    """Build container-image-host with incus profile."""
    poky_dir = Path(request.config.getoption("--poky-dir"))
    bd = request.config.getoption("--build-dir")
    build_dir = Path(bd) if bd else poky_dir / "build"
    result = _run_bitbake(
        build_dir, "container-image-host",
        extra_vars={
            "CONTAINER_PROFILE": "incus",
            "DISTRO_FEATURES:append": " virtualization",
        },
    )
    if result.returncode != 0:
        pytest.fail(f"Incus image build failed: {result.stderr}")


@pytest.fixture(scope="module")
def incus_qemu(request, incus_image):
    """Build incus image, boot a QEMU VM, and return the pexpect session."""
    machine = request.config.getoption("--machine", default="qemux86-64")
    boot_timeout = int(request.config.getoption("--boot-timeout", default="120"))
    no_kvm = request.config.getoption("--no-kvm", default=False)

    poky_dir = Path(request.config.getoption("--poky-dir"))
    bd = request.config.getoption("--build-dir")
    builddir = str(Path(bd) if bd else poky_dir / "build")

    kvm_opt = "" if no_kvm else "kvm"
    cmd = f"runqemu {machine} container-image-host ext4 nographic slirp {kvm_opt} qemuparams=\"-m 4096\""

    child = pexpect.spawn(f"bash -c 'cd {poky_dir} && source oe-init-build-env {builddir} >/dev/null 2>&1 && {cmd}'",
                          timeout=boot_timeout, encoding="utf-8", logfile=None)

    # Wait for login prompt
    child.expect(r"login:", timeout=boot_timeout)
    child.sendline("root")
    child.expect(r"root@.*[:~#]", timeout=30)

    # Suppress shell integration escape sequences
    child.sendline("export TERM=dumb")
    child.expect(r"root@.*[:~#]", timeout=10)

    yield child

    # Cleanup
    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 the output."""
    marker = f"__MARKER_{time.monotonic_ns()}__"
    child.sendline(f"{cmd}; echo {marker} $?")
    child.expect(marker + r" (\d+)", timeout=timeout)
    output = child.before.strip()
    rc = int(child.match.group(1))
    # consume prompt
    child.expect(r"root@.*[:~#]", timeout=10)
    return output, rc


class TestIncusDaemon:
    """Test that incusd starts and is functional."""

    def test_incusd_running(self, incus_qemu):
        """incusd should be running via systemd."""
        output, rc = run_cmd(incus_qemu, "systemctl is-active incus.service")
        assert "active" in output, f"incus.service not active: {output}"

    def test_incus_admin_group(self, incus_qemu):
        """incus-admin group should exist."""
        output, rc = run_cmd(incus_qemu, "getent group incus-admin")
        assert rc == 0, "incus-admin group not found"

    def test_incus_version(self, incus_qemu):
        """incus client should report a version."""
        output, rc = run_cmd(incus_qemu, "incus version")
        assert rc == 0, f"incus version failed: {output}"


class TestIncusInit:
    """Test incus initialization."""

    def test_incus_init_minimal(self, incus_qemu):
        """incus admin init --minimal should succeed."""
        output, rc = run_cmd(incus_qemu, "incus admin init --minimal", timeout=120)
        assert rc == 0, f"incus admin init --minimal failed: {output}"

    def test_incus_network_created(self, incus_qemu):
        """Default network bridge should exist after init."""
        output, rc = run_cmd(incus_qemu, "incus network list")
        assert rc == 0, f"incus network list failed: {output}"


class TestIncusContainer:
    """Test launching and managing a container."""

    def test_launch_alpine(self, incus_qemu):
        """Launch an Alpine container from the images: remote."""
        output, rc = run_cmd(incus_qemu, "incus launch images:alpine/edge incus-test1",
                             timeout=180)
        assert rc == 0, f"incus launch failed: {output}"

    def test_container_running(self, incus_qemu):
        """The launched container should be in RUNNING state."""
        output, rc = run_cmd(incus_qemu, "incus list --format csv -c n,s")
        assert rc == 0
        assert "incus-test1,RUNNING" in output.replace(" ", ""), \
            f"Container not running: {output}"

    def test_exec_in_container(self, incus_qemu):
        """Execute a command inside the container."""
        output, rc = run_cmd(incus_qemu, "incus exec incus-test1 -- cat /etc/os-release")
        assert rc == 0
        assert "Alpine" in output, f"Unexpected os-release: {output}"

    def test_stop_container(self, incus_qemu):
        """Stop the container."""
        output, rc = run_cmd(incus_qemu, "incus stop incus-test1", timeout=30)
        assert rc == 0, f"incus stop failed: {output}"

    def test_delete_container(self, incus_qemu):
        """Delete the stopped container."""
        output, rc = run_cmd(incus_qemu, "incus delete incus-test1", timeout=15)
        assert rc == 0, f"incus delete failed: {output}"

    def test_no_containers_remain(self, incus_qemu):
        """No containers should remain after cleanup."""
        output, rc = run_cmd(incus_qemu, "incus list --format csv")
        assert rc == 0
        assert "incus-test1" not in output