Files
kernalix7--winpodx/tests/test_storage_migration.py
wehub-resource-sync 3e779be6f3
CI / lint (push) Failing after 13m4s
CI / test (3.11, ubuntu-latest) (push) Failing after 2m4s
CI / test (3.13, ubuntu-latest) (push) Successful in 13m30s
CI / test (3.14, ubuntu-latest) (push) Successful in 17m21s
CI / test (3.12, ubuntu-latest) (push) Successful in 17m55s
CI / discover-apps-ps (push) Successful in 1m56s
CI / test (3.9, ubuntu-latest) (push) Successful in 13m17s
CI / test (3.10, ubuntu-latest) (push) Successful in 26m21s
CI / audit (push) Successful in 13m38s
Deploy site / deploy (push) Has been cancelled
CI / test (3.14, ubuntu-24.04-arm) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:37 +08:00

713 lines
28 KiB
Python

# SPDX-License-Identifier: MIT
"""Tests for winpodx.core.storage_migration — named-volume → bind-mount move."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
from winpodx.core import storage_migration as sm
class TestNamedVolumeExists:
def test_returns_false_for_unsupported_backend(self):
assert sm.named_volume_exists("libvirt") is False
def test_returns_false_when_backend_binary_missing(self):
with patch.object(sm.shutil, "which", return_value=None):
assert sm.named_volume_exists("podman") is False
def test_true_when_volume_exists_returncode_zero(self):
with (
patch.object(sm.shutil, "which", return_value="/usr/bin/podman"),
patch.object(sm.subprocess, "run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert sm.named_volume_exists("podman") is True
def test_false_when_volume_exists_returncode_nonzero(self):
with (
patch.object(sm.shutil, "which", return_value="/usr/bin/podman"),
patch.object(sm.subprocess, "run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
assert sm.named_volume_exists("podman") is False
class TestGetVolumeMountpoint:
def test_parses_inspect_json(self):
payload = json.dumps(
[{"Mountpoint": "/var/lib/containers/storage/volumes/winpodx-data/_data"}]
)
with (
patch.object(sm.shutil, "which", return_value="/usr/bin/podman"),
patch.object(sm.subprocess, "run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0, stdout=payload, stderr="")
mp = sm.get_volume_mountpoint("podman")
assert mp == Path("/var/lib/containers/storage/volumes/winpodx-data/_data")
def test_returns_none_on_inspect_error(self):
with (
patch.object(sm.shutil, "which", return_value="/usr/bin/podman"),
patch.object(sm.subprocess, "run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="no such")
assert sm.get_volume_mountpoint("podman") is None
def test_returns_none_on_malformed_json(self):
with (
patch.object(sm.shutil, "which", return_value="/usr/bin/podman"),
patch.object(sm.subprocess, "run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0, stdout="not-json", stderr="")
assert sm.get_volume_mountpoint("podman") is None
class TestPlanMigration:
def _cfg(self, tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
from winpodx.core.config import Config
cfg = Config()
cfg.pod.backend = "podman"
cfg.save()
return cfg
def test_returns_error_string_on_unsupported_backend(self, tmp_path, monkeypatch):
cfg = self._cfg(tmp_path, monkeypatch)
cfg.pod.backend = "libvirt"
result = sm.plan_migration(cfg, target=tmp_path / "target")
assert isinstance(result, str)
assert "named volumes" in result
def test_returns_error_string_when_no_named_volume(self, tmp_path, monkeypatch):
cfg = self._cfg(tmp_path, monkeypatch)
with patch.object(sm, "resolve_named_volume", return_value=None):
result = sm.plan_migration(cfg, target=tmp_path / "target")
assert isinstance(result, str)
# Error mentions both candidate names so the user can grep for either.
assert "winpodx-data" in result and "winpodx_winpodx-data" in result
def test_returns_error_string_when_target_not_empty(self, tmp_path, monkeypatch):
cfg = self._cfg(tmp_path, monkeypatch)
target = tmp_path / "target"
target.mkdir()
(target / "junk").write_text("hi")
src = tmp_path / "src"
src.mkdir()
with (
patch.object(sm, "resolve_named_volume", return_value="winpodx-data"),
patch.object(sm, "get_volume_mountpoint", return_value=src),
):
result = sm.plan_migration(cfg, target=target)
assert isinstance(result, str)
assert "not empty" in result
def test_returns_plan_on_happy_path(self, tmp_path, monkeypatch):
cfg = self._cfg(tmp_path, monkeypatch)
target = tmp_path / "target"
src = tmp_path / "src"
src.mkdir()
(src / "win.qcow2").write_bytes(b"x" * 1024)
with (
patch.object(sm, "resolve_named_volume", return_value="winpodx-data"),
patch.object(sm, "get_volume_mountpoint", return_value=src),
patch.object(sm, "detect_path_fs", return_value="btrfs"),
):
plan = sm.plan_migration(cfg, target=target)
assert isinstance(plan, sm.MigrationPlan)
assert plan.source_mountpoint == src
assert plan.target_path == target
assert plan.target_fs == "btrfs"
assert plan.chattr_will_run is True
assert plan.source_size_bytes >= 1024
class TestExecuteMigration:
def _cfg(self, tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
from winpodx.core.config import Config
cfg = Config()
cfg.pod.backend = "podman"
cfg.save()
return cfg
def test_failed_when_pod_stop_fails(self, tmp_path, monkeypatch):
cfg = self._cfg(tmp_path, monkeypatch)
plan = sm.MigrationPlan(
backend="podman",
source_volume="winpodx-data",
source_mountpoint=tmp_path / "src",
source_size_bytes=0,
target_path=tmp_path / "target",
target_fs="ext4",
chattr_will_run=False,
free_bytes_target=0,
)
with patch.object(sm, "_stop_pod", return_value=(False, "compose down failed")):
result = sm.execute_migration(cfg, plan, start_pod=False)
assert result.status == "failed"
assert "stop pod" in result.detail
def test_happy_path_persists_storage_path_and_removes_volume(self, tmp_path, monkeypatch):
cfg = self._cfg(tmp_path, monkeypatch)
src = tmp_path / "src"
src.mkdir()
target = tmp_path / "target"
plan = sm.MigrationPlan(
backend="podman",
source_volume="winpodx-data",
source_mountpoint=src,
source_size_bytes=1024,
target_path=target,
target_fs="ext4",
chattr_will_run=False,
free_bytes_target=10 << 30,
)
# Patch every external touch point: stop pod, copy, generate
# compose, podman volume rm. We don't need real shell calls.
with (
patch.object(sm, "_stop_pod", return_value=(True, "stopped")),
patch.object(sm, "_rsync_copy", return_value=(True, "ok")),
patch("winpodx.core.compose.generate_compose"),
patch.object(sm.shutil, "which", return_value="/usr/bin/podman"),
patch.object(sm.subprocess, "run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
result = sm.execute_migration(cfg, plan, start_pod=False)
assert result.status == "ok"
# cfg.pod.storage_path was persisted to disk.
from winpodx.core.config import Config
loaded = Config.load()
assert loaded.pod.storage_path == str(target)
def test_failed_copy_cleans_up_target_dir(self, tmp_path, monkeypatch):
cfg = self._cfg(tmp_path, monkeypatch)
src = tmp_path / "src"
src.mkdir()
target = tmp_path / "target"
plan = sm.MigrationPlan(
backend="podman",
source_volume="winpodx-data",
source_mountpoint=src,
source_size_bytes=1024,
target_path=target,
target_fs="ext4",
chattr_will_run=False,
free_bytes_target=10 << 30,
)
with (
patch.object(sm, "_stop_pod", return_value=(True, "stopped")),
patch.object(sm, "_rsync_copy", return_value=(False, "disk full")),
):
result = sm.execute_migration(cfg, plan, start_pod=False)
assert result.status == "failed"
assert "disk full" in result.detail
# target dir should be gone (or empty) after cleanup
assert not target.exists() or not any(target.iterdir())
class TestComposeRendersBothModes:
"""End-to-end check that the compose template handles both storage modes."""
def _cfg(self, tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
from winpodx.core.config import Config
cfg = Config()
cfg.pod.backend = "podman"
cfg.rdp.user = "User"
cfg.rdp.password = "Test12345!"
cfg.save()
return cfg
def test_named_volume_when_storage_path_empty(self, tmp_path, monkeypatch):
cfg = self._cfg(tmp_path, monkeypatch)
cfg.pod.storage_path = ""
from winpodx.core.compose import _build_compose_content
content = _build_compose_content(cfg)
assert "volumes:\n winpodx-data:" in content
assert "- winpodx-data:/storage:Z" in content
def test_bind_mount_when_storage_path_set(self, tmp_path, monkeypatch):
cfg = self._cfg(tmp_path, monkeypatch)
bind = tmp_path / "bind"
cfg.pod.storage_path = str(bind)
from winpodx.core.compose import _build_compose_content
content = _build_compose_content(cfg)
# Top-level named-volume declaration must be gone
assert " winpodx-data:" not in content
# Bind mount line must be present
assert f"- {bind}:/storage:Z" in content
def test_unsafe_storage_path_falls_back_to_named_volume(self, tmp_path, monkeypatch):
"""Defence: a path with newline / quote can't be safely interpolated."""
cfg = self._cfg(tmp_path, monkeypatch)
cfg.pod.storage_path = "/tmp/evil\nfoo"
from winpodx.core.compose import _build_compose_content
content = _build_compose_content(cfg)
# Falls back to named volume, no bind mount line
assert "- winpodx-data:/storage:Z" in content
assert "evil" not in content
# --- Additional hardening tests (Security review follow-up) ---
class TestComposeRejectsColonInPath:
"""Compose `_render_storage_blocks` must drop to named volume when
`storage_path` contains `:` — otherwise `/tmp/x:/etc/shadow` would
bind /etc/shadow as the storage target.
"""
def _cfg(self, tmp_path, monkeypatch, storage_path):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
from winpodx.core.config import Config
cfg = Config()
cfg.pod.backend = "podman"
cfg.rdp.user = "User"
cfg.rdp.password = "Test12345!"
# Bypass __post_init__'s allowlist so we can inject a
# malicious value that mimics a hand-edited TOML — that's the
# exact bypass scenario this test guards against.
cfg.pod.storage_path = storage_path
cfg.save()
return cfg
def test_path_with_colon_falls_back_to_named_volume(self, tmp_path, monkeypatch):
cfg = self._cfg(tmp_path, monkeypatch, storage_path="/tmp/x:/etc/shadow")
from winpodx.core.compose import _build_compose_content
content = _build_compose_content(cfg)
# Named-volume mode → top-level `winpodx-data:` declaration present
assert " winpodx-data:" in content
# The malicious target must not appear anywhere
assert "/etc/shadow" not in content
class TestExecuteMigrationDefersVolumeRm:
"""`execute_migration` must not remove the named volume until the
pod has successfully started on the new bind mount. If pod start
fails, the legacy volume stays in place so the user can roll back.
"""
def _cfg(self, tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
from winpodx.core.config import Config
cfg = Config()
cfg.pod.backend = "podman"
cfg.save()
return cfg
def test_pod_start_failure_keeps_named_volume(self, tmp_path, monkeypatch):
cfg = self._cfg(tmp_path, monkeypatch)
src = tmp_path / "src"
src.mkdir()
target = tmp_path / "target"
plan = sm.MigrationPlan(
backend="podman",
source_volume="winpodx-data",
source_mountpoint=src,
source_size_bytes=1024,
target_path=target,
target_fs="ext4",
chattr_will_run=False,
free_bytes_target=10 << 30,
)
volume_rm_called = []
def track_run(cmd, **_kwargs):
volume_rm_called.append(cmd)
return MagicMock(returncode=0, stdout="", stderr="")
with (
patch.object(sm, "_stop_pod", return_value=(True, "stopped")),
patch.object(sm, "_rsync_copy", return_value=(True, "ok")),
patch("winpodx.core.compose.generate_compose"),
patch.object(sm.shutil, "which", return_value="/usr/bin/podman"),
patch.object(sm.subprocess, "run", side_effect=track_run),
patch(
"winpodx.core.provisioner.ensure_ready",
side_effect=RuntimeError("pod start failed"),
),
):
result = sm.execute_migration(cfg, plan, start_pod=True)
assert result.status == "failed"
assert "pod start failed" in result.detail
# The retry path message should be present
assert (
"winpodx setup --migrate-storage" in result.detail
or "rollback" in result.detail.lower()
or "roll back" in result.detail.lower()
)
# CRITICAL: `volume rm` must NOT have been invoked while pod start failed
assert not any("rm" in cmd for cmd in volume_rm_called), (
f"volume rm called despite pod start failure: {volume_rm_called!r}"
)
def test_pod_start_success_removes_named_volume(self, tmp_path, monkeypatch):
cfg = self._cfg(tmp_path, monkeypatch)
src = tmp_path / "src"
src.mkdir()
target = tmp_path / "target"
plan = sm.MigrationPlan(
backend="podman",
source_volume="winpodx-data",
source_mountpoint=src,
source_size_bytes=1024,
target_path=target,
target_fs="ext4",
chattr_will_run=False,
free_bytes_target=10 << 30,
)
volume_rm_called = []
def track_run(cmd, **_kwargs):
volume_rm_called.append(cmd)
return MagicMock(returncode=0, stdout="", stderr="")
with (
patch.object(sm, "_stop_pod", return_value=(True, "stopped")),
patch.object(sm, "_rsync_copy", return_value=(True, "ok")),
patch("winpodx.core.compose.generate_compose"),
patch.object(sm.shutil, "which", return_value="/usr/bin/podman"),
patch.object(sm.subprocess, "run", side_effect=track_run),
patch("winpodx.core.provisioner.ensure_ready"),
):
result = sm.execute_migration(cfg, plan, start_pod=True)
assert result.status == "ok"
# `volume rm` must have run after successful pod start
assert any(cmd[:3] == ["podman", "volume", "rm"] for cmd in volume_rm_called), (
f"volume rm not called after pod start success: {volume_rm_called!r}"
)
# --- Compose-prefix volume name resolution (#126 follow-up) ---
class TestResolveNamedVolume:
"""podman-compose namespaces volumes by project, so the actual
volume on disk is `winpodx_winpodx-data` not `winpodx-data`. The
resolver must find either form so auto-migration doesn't silently
skip on a compose-managed install (kernalix7 hit this on opensuse
Tumbleweed 2026-05-06 — auto-migrate had been a no-op for hours).
"""
def test_returns_prefixed_name_when_only_prefixed_exists(self):
seen = []
def fake_run(cmd, **_):
seen.append(cmd)
# First probe: prefixed name → exists; second probe never reached.
if cmd == ["podman", "volume", "exists", "winpodx_winpodx-data"]:
return MagicMock(returncode=0, stdout="", stderr="")
return MagicMock(returncode=1, stdout="", stderr="")
with (
patch.object(sm.shutil, "which", return_value="/usr/bin/podman"),
patch.object(sm.subprocess, "run", side_effect=fake_run),
):
result = sm.resolve_named_volume("podman")
assert result == "winpodx_winpodx-data"
# Should have probed prefixed first (preferred form)
assert seen[0] == ["podman", "volume", "exists", "winpodx_winpodx-data"]
def test_returns_bare_name_when_only_bare_exists(self):
def fake_run(cmd, **_):
if cmd == ["podman", "volume", "exists", "winpodx-data"]:
return MagicMock(returncode=0, stdout="", stderr="")
return MagicMock(returncode=1, stdout="", stderr="")
with (
patch.object(sm.shutil, "which", return_value="/usr/bin/podman"),
patch.object(sm.subprocess, "run", side_effect=fake_run),
):
result = sm.resolve_named_volume("podman")
assert result == "winpodx-data"
def test_returns_none_when_neither_exists(self):
with (
patch.object(sm.shutil, "which", return_value="/usr/bin/podman"),
patch.object(sm.subprocess, "run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
assert sm.resolve_named_volume("podman") is None
def test_named_volume_exists_uses_resolver(self):
with patch.object(sm, "resolve_named_volume", return_value="winpodx_winpodx-data"):
assert sm.named_volume_exists("podman") is True
with patch.object(sm, "resolve_named_volume", return_value=None):
assert sm.named_volume_exists("podman") is False
def test_get_volume_mountpoint_uses_resolved_name_for_inspect(self):
"""Regression: inspect must run on the resolved name, not the bare const."""
seen_inspect = []
def fake_run(cmd, **_):
if cmd[:3] == ["podman", "volume", "exists"]:
# Only the prefixed name exists.
return MagicMock(
returncode=0 if cmd[3] == "winpodx_winpodx-data" else 1,
stdout="",
stderr="",
)
if cmd[:3] == ["podman", "volume", "inspect"]:
seen_inspect.append(cmd[3])
return MagicMock(
returncode=0,
stdout=json.dumps(
[{"Mountpoint": "/var/lib/containers/.../winpodx_winpodx-data/_data"}]
),
stderr="",
)
return MagicMock(returncode=1, stdout="", stderr="")
with (
patch.object(sm.shutil, "which", return_value="/usr/bin/podman"),
patch.object(sm.subprocess, "run", side_effect=fake_run),
):
mp = sm.get_volume_mountpoint("podman")
assert mp is not None
# `inspect` must have been called on the prefixed name (the
# resolved one), not the bare default.
assert "winpodx_winpodx-data" in seen_inspect
assert "winpodx-data" not in seen_inspect
class TestPlanMigrationUsesResolvedVolume:
"""plan_migration must build the MigrationPlan with the *resolved*
(existing) volume name so that volume rm later actually targets the
real volume.
"""
def _cfg(self, tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
from winpodx.core.config import Config
cfg = Config()
cfg.pod.backend = "podman"
cfg.save()
return cfg
def test_plan_records_compose_prefixed_volume_when_that_exists(self, tmp_path, monkeypatch):
cfg = self._cfg(tmp_path, monkeypatch)
target = tmp_path / "target"
src = tmp_path / "src"
src.mkdir()
with (
patch.object(sm, "resolve_named_volume", return_value="winpodx_winpodx-data"),
patch.object(sm, "get_volume_mountpoint", return_value=src),
patch.object(sm, "detect_path_fs", return_value="btrfs"),
):
plan = sm.plan_migration(cfg, target=target)
assert isinstance(plan, sm.MigrationPlan)
assert plan.source_volume == "winpodx_winpodx-data"
def test_plan_records_bare_volume_when_that_exists(self, tmp_path, monkeypatch):
cfg = self._cfg(tmp_path, monkeypatch)
target = tmp_path / "target"
src = tmp_path / "src"
src.mkdir()
with (
patch.object(sm, "resolve_named_volume", return_value="winpodx-data"),
patch.object(sm, "get_volume_mountpoint", return_value=src),
patch.object(sm, "detect_path_fs", return_value="btrfs"),
):
plan = sm.plan_migration(cfg, target=target)
assert isinstance(plan, sm.MigrationPlan)
assert plan.source_volume == "winpodx-data"
class TestChattrPostMigrationVerification:
"""When chattr +C silently no-ops, the migration produces a CoW disk
image even though the code thought NoCoW was on. kernalix7 hit this
on opensuse Tumbleweed (2026-05-06): the auto-migration claimed
success but `lsattr -d` on the bind-mount dir AND on the 64 GiB
`data.img` showed no C flag, so the entire NoCoW benefit was lost.
These tests guard the post-rsync verification that surfaces the
discrepancy as a warning in `MigrationResult.detail` instead of
silently claiming success.
"""
def _cfg(self, tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
from winpodx.core.config import Config
cfg = Config()
cfg.pod.backend = "podman"
cfg.save()
return cfg
def _plan(self, src, target):
return sm.MigrationPlan(
backend="podman",
source_volume="winpodx-data",
source_mountpoint=src,
source_size_bytes=64 << 30,
target_path=target,
target_fs="btrfs",
chattr_will_run=True,
free_bytes_target=200 << 30,
)
def test_warns_when_target_dir_missing_cow_flag_after_migration(self, tmp_path, monkeypatch):
"""The dir should have +C after step 2; if lsattr says it doesn't
after rsync (impossible in normal kernels but seen on Tumbleweed),
the result detail must carry a warning summary so the caller can
print it inline. Status stays 'ok' because data did move."""
cfg = self._cfg(tmp_path, monkeypatch)
src = tmp_path / "src"
src.mkdir()
target = tmp_path / "target"
# Simulate: chattr +C reported success, but lsattr afterward shows
# no C flag (the actual silent-no-op kernalix7 hit).
with (
patch.object(sm, "_stop_pod", return_value=(True, "stopped")),
patch.object(sm, "_rsync_copy", return_value=(True, "ok")),
patch.object(sm, "disable_cow_on_path", return_value=("disabled", "ok")),
patch.object(sm, "is_cow_disabled", return_value=False),
patch("winpodx.core.compose.generate_compose"),
patch.object(sm.shutil, "which", return_value="/usr/bin/podman"),
patch.object(sm.subprocess, "run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
result = sm.execute_migration(cfg, plan=self._plan(src, target), start_pod=False)
assert result.status == "ok"
assert "warning(s)" in result.detail.lower()
def test_warns_when_sample_file_did_not_inherit_cow_flag(self, tmp_path, monkeypatch):
"""Even if the dir has +C, files born inside don't always inherit
on every kernel/version. Sampling the first .img file post-rsync
catches the case where the 64 GiB data.img landed CoW despite +C
on the parent."""
cfg = self._cfg(tmp_path, monkeypatch)
src = tmp_path / "src"
src.mkdir()
target = tmp_path / "target"
# Realistic _rsync_copy stand-in: actually create a fake data.img
# in the target so the iterdir scan finds something to sample.
def fake_rsync(_src, dst):
(dst / "data.img").write_bytes(b"x")
return True, "ok"
# is_cow_disabled returns True for the dir, False for the file.
def fake_is_cow_disabled(path):
if path == target:
return True
return False # file did not inherit
with (
patch.object(sm, "_stop_pod", return_value=(True, "stopped")),
patch.object(sm, "_rsync_copy", side_effect=fake_rsync),
patch.object(sm, "disable_cow_on_path", return_value=("disabled", "ok")),
patch.object(sm, "is_cow_disabled", side_effect=fake_is_cow_disabled),
patch("winpodx.core.compose.generate_compose"),
patch.object(sm.shutil, "which", return_value="/usr/bin/podman"),
patch.object(sm.subprocess, "run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
result = sm.execute_migration(cfg, plan=self._plan(src, target), start_pod=False)
assert result.status == "ok"
# The detail summary should flag at least one warning.
assert "warning(s)" in result.detail.lower()
def test_no_warning_when_dir_and_sample_both_have_cow_flag(self, tmp_path, monkeypatch):
"""Happy path: dir has +C, sample file inherits +C → result detail
is the clean summary, no warning suffix."""
cfg = self._cfg(tmp_path, monkeypatch)
src = tmp_path / "src"
src.mkdir()
target = tmp_path / "target"
def fake_rsync(_src, dst):
(dst / "data.img").write_bytes(b"x")
return True, "ok"
with (
patch.object(sm, "_stop_pod", return_value=(True, "stopped")),
patch.object(sm, "_rsync_copy", side_effect=fake_rsync),
patch.object(sm, "disable_cow_on_path", return_value=("disabled", "ok")),
patch.object(sm, "is_cow_disabled", return_value=True),
patch("winpodx.core.compose.generate_compose"),
patch.object(sm.shutil, "which", return_value="/usr/bin/podman"),
patch.object(sm.subprocess, "run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
result = sm.execute_migration(cfg, plan=self._plan(src, target), start_pod=False)
assert result.status == "ok"
assert "warning(s)" not in result.detail.lower()
def test_no_verification_when_chattr_will_not_run(self, tmp_path, monkeypatch):
"""Non-btrfs target → no verify step at all; result detail clean."""
cfg = self._cfg(tmp_path, monkeypatch)
src = tmp_path / "src"
src.mkdir()
target = tmp_path / "target"
plan = sm.MigrationPlan(
backend="podman",
source_volume="winpodx-data",
source_mountpoint=src,
source_size_bytes=1024,
target_path=target,
target_fs="ext4", # not btrfs
chattr_will_run=False,
free_bytes_target=10 << 30,
)
with (
patch.object(sm, "_stop_pod", return_value=(True, "stopped")),
patch.object(sm, "_rsync_copy", return_value=(True, "ok")),
patch.object(sm, "is_cow_disabled") as mock_check,
patch("winpodx.core.compose.generate_compose"),
patch.object(sm.shutil, "which", return_value="/usr/bin/podman"),
patch.object(sm.subprocess, "run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
result = sm.execute_migration(cfg, plan, start_pod=False)
assert result.status == "ok"
assert "warning(s)" not in result.detail.lower()
# Verification is gated on chattr_will_run; the helper is never
# invoked for non-btrfs targets.
mock_check.assert_not_called()