# SPDX-License-Identifier: MIT """Architecture-aware compose template generation tests. After the #287 refactor, CPU sub-flags live in the dedicated ``CPU_FLAGS:`` env (consumed by dockur's ``proc.sh``) rather than being injected through ``ARGUMENTS:``. ``ARGUMENTS:`` now carries only the non-``-cpu`` extras (virtio-rng device pair). dockur owns the hv-* enlightenments via its ``HV=Y`` default and the nested-virt sub-flags via the ``VMX=Y`` env. The remaining x86 vs aarch64 split is in ``CPU_FLAGS``: - x86_64: ``arch_capabilities=off`` (#141 / #140 -- Intel-only bits crash Windows when leaked through) plus ``+invtsc`` when supported (#215). - aarch64: empty CPU_FLAGS -- dockur picks the right CPU on ARM. """ from __future__ import annotations import pytest import winpodx.core.config as _config_module import winpodx.core.pod.compose as _compose_module from winpodx.core.config import Config from winpodx.core.pod.compose import _build_compose_content @pytest.fixture(autouse=True) def _stub_smbios_blob_write(monkeypatch): """Keep these tests hermetic: don't write a real SMBIOS blob to the OEM dir. The disguise (#246, T1.5) writes a synthetic SMBIOS blob during compose generation; stub the writer so the `-smbios file=` arg is still added but no file I/O happens. tests/test_smbios.py covers the real encode + write path. """ monkeypatch.setattr( _compose_module, "_write_disguise_smbios_blob", lambda oem_dir: "/oem/winpodx-smbios.bin", ) def _cfg() -> Config: cfg = Config() cfg.pod.backend = "podman" cfg.rdp.user = "User" cfg.rdp.password = "TestPassword1!" cfg.rdp.port = 3390 cfg.pod.vnc_port = 8007 cfg.pod.container_name = "winpodx-windows" # Baseline architecture tests must stay independent of host capability # detection (#215). Turn off the auto tuner so the CPU_FLAGS reflects # only the architecture branch under test. cfg.pod.tuning_profile = "off" # Likewise isolate from the default-ON hypervisor disguise (#246) — the # arch/tuning tests assert an exact CPU_FLAGS string. The disguise tests # below set the level explicitly. cfg.pod.disguise_level = "off" return cfg def test_compose_cpu_flags_x86_64(monkeypatch): """x86_64 hosts emit ``arch_capabilities=off`` via CPU_FLAGS env.""" monkeypatch.setattr(_compose_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "x86_64") content = _build_compose_content(_cfg()) assert 'CPU_FLAGS: "arch_capabilities=off"' in content def test_compose_ssd_emulation_off_by_default(monkeypatch): monkeypatch.setattr(_compose_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "x86_64") content = _build_compose_content(_cfg()) assert "rotation_rate" not in content def test_compose_ssd_emulation_injects_rotation_rate(monkeypatch): # #606: pod.ssd -> -global .rotation_rate=1 so Windows sees an SSD. monkeypatch.setattr(_compose_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "x86_64") cfg = _cfg() cfg.pod.ssd = True content = _build_compose_content(cfg) assert "ide-hd.rotation_rate=1" in content assert "scsi-hd.rotation_rate=1" in content def test_compose_cpu_flags_aarch64(monkeypatch): """aarch64 hosts emit empty CPU_FLAGS -- dockur picks the host CPU. Regression guard for issue #140: passing ``arch_capabilities=off`` on aarch64 crashes QEMU with ``Property 'host-arm-cpu.arch_capabilities' not found``. """ monkeypatch.setattr(_compose_module.platform, "machine", lambda: "aarch64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "aarch64") content = _build_compose_content(_cfg()) assert 'CPU_FLAGS: ""' in content assert "arch_capabilities" not in content def test_compose_cpu_flags_unknown_arch_falls_through_to_x86(monkeypatch): """Unknown / unexpected machine() value falls through to the x86_64 behaviour. Intentional: an unsupported platform should surface a clear QEMU error at pod start rather than silently using a partly- correct ARM config. """ monkeypatch.setattr(_compose_module.platform, "machine", lambda: "riscv64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "riscv64") content = _build_compose_content(_cfg()) assert 'CPU_FLAGS: "arch_capabilities=off"' in content def test_compose_cpu_flags_invtsc_off_profile_does_not_append(monkeypatch): """``cfg.pod.tuning_profile = "off"`` keeps CPU_FLAGS at the baseline (``arch_capabilities=off`` only) even on an invtsc-capable host.""" import winpodx.utils.specs as specs monkeypatch.setattr(_compose_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr( specs, "detect_tuning_capability", lambda *, vm_cpu_cores, vm_ram_gb: specs.TuningCapability( invtsc=True, io_uring=True, hugepages_enabled=False, dedicated_host=False, kernel_version=(6, 18), cpu_vendor="intel", nested_kvm=False, ), ) cfg = _cfg() cfg.pod.tuning_profile = "off" content = _build_compose_content(cfg) assert 'CPU_FLAGS: "arch_capabilities=off"' in content # --- Bare-metal compatibility / hypervisor disguise (#246, default ON) --- _DISGUISE_FLAGS = ( "-hypervisor", "kvm=off", "-kvm-pv-eoi", "-kvm-pv-unhalt", "-kvm-pv-tlb-flush", "-kvm-asyncpf", ) def test_compose_disguise_on_by_default(monkeypatch): """disguise_level=None (absent) resolves to balanced — emits the flags.""" monkeypatch.setattr(_compose_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "x86_64") cfg = _cfg() cfg.pod.disguise_level = None # the real default (absent key) cfg.pod.__post_init__() # re-resolve: None -> "balanced" cfg.pod.storage_path = "" # named-volume mode → host-trusted disk bump assert cfg.pod.disguise_level == "balanced" assert cfg.pod.disguise_active is True content = _build_compose_content(cfg) for flag in _DISGUISE_FLAGS: assert flag in content, f"disguise flag {flag!r} missing (default ON)" # hv-vendor-id was deliberately dropped (collides with dockur hv flags). assert "hv-vendor-id" not in content # balanced keeps Hyper-V on (perf); the disk is bumped to a real size. assert 'HV: "Y"' in content assert 'DISK_SIZE: "128G"' in content def test_compose_disguise_legacy_bool_false_maps_to_off(monkeypatch): """A pre-tier config (disguise_hypervisor=False) migrates to level off.""" monkeypatch.setattr(_config_module.platform, "machine", lambda: "x86_64") cfg = _cfg() cfg.pod.disguise_level = None cfg.pod.disguise_hypervisor = False cfg.pod.__post_init__() assert cfg.pod.disguise_level == "off" def test_compose_disguise_balanced(monkeypatch): """level=balanced emits the disguise flags, keeps HV=Y.""" monkeypatch.setattr(_compose_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "x86_64") cfg = _cfg() cfg.pod.disguise_level = "balanced" content = _build_compose_content(cfg) for flag in _DISGUISE_FLAGS: assert flag in content, f"disguise flag {flag!r} missing from compose" assert "hv-vendor-id" not in content assert 'HV: "Y"' in content def test_compose_disguise_max_uses_emulated_devices(monkeypatch): """level=max drops Hyper-V (HV=N) AND swaps virtio for emulated devices.""" monkeypatch.setattr(_compose_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "x86_64") cfg = _cfg() cfg.pod.disguise_level = "max" content = _build_compose_content(cfg) for flag in _DISGUISE_FLAGS: assert flag in content assert 'HV: "N"' in content # the max-only Hyper-V opt-out assert 'DISK_TYPE: "sata"' in content # emulated AHCI disk (no viostor/vioscsi) assert 'ADAPTER: "e1000"' in content # emulated NIC (no netkvm) assert 'MTU: "1500"' in content # required so dockur omits host_mtu (e1000 rejects it) assert 'VGA: "std"' in content # std VGA (no Red Hat QXL/virtio-gpu) assert "virtio-rng-pci" not in content # the rng would re-add VEN_1AF4 def test_compose_disguise_image_used_only_at_max(monkeypatch): """cfg.pod.disguise_image overrides the image only at level=max (#246).""" monkeypatch.setattr(_compose_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "x86_64") cfg = _cfg() cfg.pod.image = "docker.io/dockurr/windows:pinned" cfg.pod.disguise_image = "winpodx-windows-disguise" # balanced → ignore disguise_image, use the pinned image. cfg.pod.disguise_level = "balanced" content = _build_compose_content(cfg) assert "image: docker.io/dockurr/windows:pinned" in content assert "winpodx-windows-disguise" not in content # max → use the custom patched image. cfg.pod.disguise_level = "max" content = _build_compose_content(cfg) assert "image: winpodx-windows-disguise" in content def test_compose_disguise_max_injects_sensor_ssdt(monkeypatch): """Max + a built disguise_image injects the sensor SSDT via -acpitable (#246).""" monkeypatch.setattr(_compose_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "x86_64") cfg = _cfg() cfg.pod.disguise_image = "winpodx-windows-disguise" cfg.pod.disguise_level = "max" content = _build_compose_content(cfg) assert "-acpitable file=/usr/share/qemu/winpodx-ssdt-sensors.aml" in content assert "-acpitable file=/usr/share/qemu/winpodx-wsmt.aml" in content # WSMT inject # balanced → the AML only ships in the patched max image, so no injection. cfg.pod.disguise_level = "balanced" assert "winpodx-ssdt-sensors.aml" not in _build_compose_content(cfg) # max but no disguise_image built → no injection (the file wouldn't exist). cfg.pod.disguise_level = "max" cfg.pod.disguise_image = "" assert "winpodx-ssdt-sensors.aml" not in _build_compose_content(cfg) def test_disguise_changes_devices_only_at_max_boundary(): """Only crossing the max boundary changes virtual hardware (needs a wipe).""" from winpodx.core.config import disguise_changes_devices assert disguise_changes_devices("balanced", "max") is True assert disguise_changes_devices("max", "off") is True assert disguise_changes_devices("off", "balanced") is False assert disguise_changes_devices("balanced", "balanced") is False assert disguise_changes_devices("max", "max") is False def test_compose_disguise_balanced_keeps_virtio_devices(monkeypatch): """balanced leaves the device knobs at dockur's virtio defaults (empty).""" monkeypatch.setattr(_compose_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "x86_64") cfg = _cfg() cfg.pod.disguise_level = "balanced" content = _build_compose_content(cfg) assert 'DISK_TYPE: ""' in content assert 'ADAPTER: ""' in content assert 'MTU: ""' in content assert 'VGA: ""' in content def test_compose_disguise_disk_not_bumped_when_host_small(monkeypatch): """A host without room for the target leaves the disk as-is (warn) — #246.""" monkeypatch.setattr(_compose_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "x86_64") import winpodx.core.disk as _disk # 50 GiB free − 10 GiB floor = 40 GiB < 128 GiB target → must not bump. monkeypatch.setattr(_disk, "_host_free_and_total", lambda cfg: (50 * 1024**3, 100 * 1024**3)) cfg = _cfg() cfg.pod.disguise_level = "balanced" cfg.pod.disk_size = "64G" cfg.pod.storage_path = "/some/path" # non-empty → host free space consulted content = _build_compose_content(cfg) assert 'DISK_SIZE: "64G"' in content # left as-is, not enlarged def test_compose_disguise_disk_bumped_when_host_has_room(monkeypatch): """Plenty of host free space → disk advertised at 128G (sparse) — #246. Regression: the old gate reused effective_max_bytes (reserve = 10 % of the *total* filesystem), so a multi-TB host with hundreds of GB free was still skipped. The flat-floor gate bumps whenever the target fits. """ monkeypatch.setattr(_compose_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "x86_64") import winpodx.core.disk as _disk # 300 GiB free on a 4 TB disk → must bump despite the 10 %-of-total being huge. monkeypatch.setattr(_disk, "_host_free_and_total", lambda cfg: (300 * 1024**3, 4000 * 1024**3)) cfg = _cfg() cfg.pod.disguise_level = "balanced" cfg.pod.disk_size = "64G" cfg.pod.storage_path = "/some/path" content = _build_compose_content(cfg) assert 'DISK_SIZE: "128G"' in content def test_compose_disguise_off_opts_out(monkeypatch): """level=off emits no disguise flags, keeps HV=Y and the user's disk.""" monkeypatch.setattr(_compose_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "x86_64") cfg = _cfg() # _cfg() sets disguise_level = "off" assert cfg.pod.disguise_active is False content = _build_compose_content(cfg) assert "-hypervisor" not in content assert "kvm=off" not in content assert 'HV: "Y"' in content assert 'DISK_SIZE: "64G"' in content # off never bumps the disk def test_compose_disguise_smbios_mirrors_host_dmi(monkeypatch): """T1 (#246): the host's real, shell-safe DMI fields land in `-smbios`.""" monkeypatch.setattr(_compose_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "x86_64") # Synthetic fixture values (not any real machine's DMI). dmi = { "sys_vendor": "ACME", "product_name": "MDL-9000", "board_vendor": "ACME", "board_name": "BRD-42", "bios_vendor": "ACME", "bios_version": "BIOS-1.0", "bios_date": "01/01/2020", "chassis_vendor": "ACME", } monkeypatch.setattr(_compose_module, "_host_dmi_field", lambda n: dmi.get(n)) cfg = _cfg() cfg.pod.disguise_level = "balanced" content = _build_compose_content(cfg) assert "type=1,manufacturer=ACME,product=MDL-9000" in content assert "type=0,vendor=ACME,version=BIOS-1.0,date=01/01/2020" in content assert "type=3,manufacturer=ACME" in content def test_compose_disguise_smbios_skips_unsafe_dmi(monkeypatch): """Fields with spaces / unsafe chars are dropped, not mangled into ARGUMENTS.""" monkeypatch.setattr(_compose_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "x86_64") # Only product_name is unsafe (space); the vendor is safe. Emulate the # real _host_dmi_field safety filter (drop values with unsafe chars). dmi = {"sys_vendor": "ACME", "product_name": "Generic Model 9000"} def fake(n): v = dmi.get(n) return v if (v and all(c.isalnum() or c in "._/+-" for c in v)) else None monkeypatch.setattr(_compose_module, "_host_dmi_field", fake) cfg = _cfg() cfg.pod.disguise_level = "balanced" content = _build_compose_content(cfg) assert "manufacturer=ACME" in content # safe vendor kept assert "Generic Model 9000" not in content # spaced product dropped def test_compose_disguise_off_emits_no_smbios(monkeypatch): """disguise off (explicit) → no host-DMI `-smbios` override.""" monkeypatch.setattr(_compose_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr(_compose_module, "_host_dmi_field", lambda n: "ACME") cfg = _cfg() # _cfg() sets disguise_level = "off" content = _build_compose_content(cfg) assert "-smbios" not in content def test_compose_cpu_flags_invtsc_auto_profile_appends_when_supported(monkeypatch): """``tuning_profile = "auto"`` + invtsc-capable host appends ``+invtsc`` to CPU_FLAGS (#215). hv-* enlightenments are NOT emitted by us -- dockur's HV=Y handles those. """ import winpodx.utils.specs as specs monkeypatch.setattr(_compose_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr( specs, "detect_tuning_capability", lambda *, vm_cpu_cores, vm_ram_gb: specs.TuningCapability( invtsc=True, io_uring=True, hugepages_enabled=False, dedicated_host=False, kernel_version=(6, 18), cpu_vendor="amd", nested_kvm=False, ), ) cfg = _cfg() cfg.pod.tuning_profile = "auto" content = _build_compose_content(cfg) # +invtsc lands in CPU_FLAGS env now, not ARGUMENTS. assert 'CPU_FLAGS: "arch_capabilities=off,+invtsc"' in content # virtio-rng -device pair stays in ARGUMENTS (dockur doesn't add it). assert "virtio-rng-pci,rng=rng0" in content assert "rng-random,id=rng0,filename=/dev/urandom" in content # We no longer emit hv-* explicitly -- dockur owns those via HV=Y. assert "hv-relaxed" not in content assert "hv-evmcs" not in content # nested-virt CPU sub-flags also delegated -- we set VMX env instead. assert "+svm" not in content assert "+vmx" not in content # QEMU 10 dropped -no-hpet -- regression guard. assert "-no-hpet" not in content # nested_kvm=False so VMX env reads N. assert 'VMX: "N"' in content def test_compose_cpu_flags_invtsc_skipped_when_host_lacks_flag(monkeypatch): """Even with ``tuning_profile = "auto"``, a host without ``constant_tsc + nonstop_tsc`` must not get ``+invtsc`` in CPU_FLAGS -- QEMU would either silently drop it or refuse to start. """ import winpodx.utils.specs as specs monkeypatch.setattr(_compose_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr( specs, "detect_tuning_capability", lambda *, vm_cpu_cores, vm_ram_gb: specs.TuningCapability( invtsc=False, io_uring=True, hugepages_enabled=False, dedicated_host=False, kernel_version=(6, 18), cpu_vendor="intel", nested_kvm=False, ), ) cfg = _cfg() cfg.pod.tuning_profile = "auto" content = _build_compose_content(cfg) assert "+invtsc" not in content def test_compose_cpu_flags_aarch64_ignores_tuning_profile(monkeypatch): """aarch64 returns empty CPU_FLAGS regardless of profile -- invtsc and arch_capabilities are x86-only.""" import winpodx.utils.specs as specs monkeypatch.setattr(_compose_module.platform, "machine", lambda: "aarch64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "aarch64") monkeypatch.setattr( specs, "detect_tuning_capability", lambda *, vm_cpu_cores, vm_ram_gb: specs.TuningCapability( invtsc=True, io_uring=True, hugepages_enabled=False, dedicated_host=True, kernel_version=(6, 18), cpu_vendor="arm", nested_kvm=False, ), ) cfg = _cfg() cfg.pod.tuning_profile = "auto" content = _build_compose_content(cfg) assert 'CPU_FLAGS: ""' in content assert "+invtsc" not in content assert "arch_capabilities" not in content def test_compose_vmx_env_reflects_nested_virt(monkeypatch): """VMX env reads ``Y`` when tuning profile resolves nested virt on, ``N`` otherwise. dockur's proc.sh handles the actual +vmx/+svm sub-flag selection per CPU vendor. """ import winpodx.utils.specs as specs monkeypatch.setattr(_compose_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr( specs, "detect_tuning_capability", lambda *, vm_cpu_cores, vm_ram_gb: specs.TuningCapability( invtsc=True, io_uring=True, hugepages_enabled=False, dedicated_host=False, kernel_version=(6, 18), cpu_vendor="intel", nested_kvm=True, ), ) cfg = _cfg() cfg.pod.tuning_profile = "auto" content = _build_compose_content(cfg) # nested_kvm=True + auto profile -> VMX=Y. assert 'VMX: "Y"' in content def test_compose_287_workaround_no_longer_needed(monkeypatch): """After the refactor, no ``-cpu host,...`` token ever lands in ARGUMENTS, so dockur's proc.sh:137 strip code path doesn't trigger and the ``-msg timestamp=on`` workaround (#287) is no longer appended. Regression guard. """ import winpodx.utils.specs as specs monkeypatch.setattr(_compose_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr(_config_module.platform, "machine", lambda: "x86_64") monkeypatch.setattr( specs, "detect_tuning_capability", lambda *, vm_cpu_cores, vm_ram_gb: specs.TuningCapability( invtsc=True, io_uring=True, hugepages_enabled=False, dedicated_host=False, kernel_version=(6, 18), cpu_vendor="intel", nested_kvm=False, ), ) cfg = _cfg() # Every profile shape -- marker should never appear. for profile in ("off", "safe", "auto", "performance", "manual"): cfg.pod.tuning_profile = profile content = _build_compose_content(cfg) assert "-msg timestamp=on" not in content, f"marker leaked back in under profile={profile}" # And -cpu host, never appears in ARGUMENTS (the whole point). # We only need to check that ARGUMENTS lines don't contain it. for line in content.splitlines(): if "ARGUMENTS:" in line: assert "-cpu" not in line, ( f"-cpu leaked into ARGUMENTS under profile={profile}: {line}" )