Publish curated Unraid-AppOps-Gateway source from aaf7d11821a86be65c91fead97a6a4758c4fbacd

This commit is contained in:
Public Source Publisher committed 2026-10-08 13:51:07 +00:00
1 parent 76d7249b5e
commit 837c263cd8
7 files changed
+1674 -60

No files matched your search

@@ -22,6 +22,7 @@ spec = importlib.util.spec_from_file_location(
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
REVISION = "a" * 40
OLD_REVISION = "c" * 40
IMAGE_ID = "sha256:" + "b" * 64
ORIGINAL_COMPOSE = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: immutable-controller\n"
@@ -830,7 +831,7 @@ def test_compose_uses_protected_code_and_two_config_views(compose):
"source": module._expected_revision_path(REVISION).as_posix(),
"target": module.EXECUTABLE_TARGET,
"read_only": True,
"bind": {"propagation": "rprivate"},
"bind": {"propagation": "rslave"},
} in volumes
assert {
"type": "volume",
@@ -849,7 +850,7 @@ def test_compose_uses_protected_code_and_two_config_views(compose):
"source": module._expected_config_path().as_posix(),
"target": module.CONFIG_TARGET,
"read_only": True,
"bind": {"propagation": "rprivate"},
"bind": {"propagation": "rslave"},
} in volumes
assert len(volumes) == 5
volumes.remove(next(item for item in volumes if item["target"] == module.EXECUTABLE_TARGET))
@@ -899,7 +900,7 @@ def test_compose_migrates_legacy_config_alias_to_explicit_private_bind(compose):
"source": module._expected_config_path().as_posix(),
"target": module.CONFIG_TARGET,
"read_only": True,
"bind": {"propagation": "rprivate"},
"bind": {"propagation": "rslave"},
}
]
@@ -924,8 +925,8 @@ def test_compose_accepts_empty_bind_normalization_and_canonicalizes_both_managed
if mount.get("target") in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}
]
assert {item["target"]: item["bind"] for item in managed} == {
module.EXECUTABLE_TARGET: {"propagation": "rprivate"},
module.CONFIG_TARGET: {"propagation": "rprivate"},
module.EXECUTABLE_TARGET: {"propagation": "rslave"},
module.CONFIG_TARGET: {"propagation": "rslave"},
}
@@ -947,7 +948,7 @@ def test_compose_normalizes_all_observed_live_generated_bind_defaults(compose):
@pytest.mark.parametrize("bind", [
{"create_host_path": 1}, {"create_host_path": "true"},
{"create_host_path": False},
{"create_host_path": True, "propagation": "rslave"},
{"create_host_path": True, "propagation": "rshared"},
{"create_host_path": True, "unknown": True},
])
def test_compose_rejects_unsafe_generated_docker_root_bind(compose, bind):
@@ -960,7 +961,7 @@ def test_compose_rejects_unsafe_generated_docker_root_bind(compose, bind):
@pytest.mark.parametrize("target", [module.EXECUTABLE_TARGET, module.CONFIG_TARGET])
@pytest.mark.parametrize("private", [False, True])
@pytest.mark.parametrize("private", [None, "rprivate", "rslave"])
def test_compose_normalizes_generated_create_host_path_for_fixed_readonly_mounts(
compose, target, private
):
@@ -968,7 +969,7 @@ def test_compose_normalizes_generated_create_host_path_for_fixed_readonly_mounts
volumes = baseline["services"][module.SERVICE]["volumes"]
bind = {"create_host_path": True}
if private:
bind["propagation"] = "rprivate"
bind["propagation"] = private
if target == module.EXECUTABLE_TARGET:
mount = next(item for item in volumes if item["target"] == target)
mount["bind"] = bind
@@ -983,7 +984,7 @@ def test_compose_normalizes_generated_create_host_path_for_fixed_readonly_mounts
if mount["target"] in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}]
assert len(managed) == 2
assert all(mount["read_only"] is True for mount in managed)
assert all(mount["bind"] == {"propagation": "rprivate"} for mount in managed)
assert all(mount["bind"] == {"propagation": "rslave"} for mount in managed)
assert rendered["services"]["runner"] == baseline["services"]["runner"]
# Only captured source metadata is normalized. Activation stays canonical.
module._bound_model(json.dumps(rendered).encode())
@@ -996,7 +997,7 @@ def test_compose_normalizes_generated_create_host_path_for_fixed_readonly_mounts
@pytest.mark.parametrize("bind", [
{"create_host_path": 1},
{"create_host_path": "true"},
{"create_host_path": True, "propagation": "rslave"},
{"create_host_path": True, "propagation": "rshared"},
{"create_host_path": True, "selinux": "z"},
])
def test_compose_rejects_unsafe_or_untyped_generated_bind_metadata(compose, target, bind):
@@ -1016,12 +1017,12 @@ def test_compose_rejects_unsafe_or_untyped_generated_bind_metadata(compose, targ
@pytest.mark.parametrize(
("target", "bind"),
[
(module.EXECUTABLE_TARGET, {"propagation": "rslave"}),
(module.EXECUTABLE_TARGET, {"propagation": "rshared"}),
(module.EXECUTABLE_TARGET, {"propagation": "shared"}),
(module.EXECUTABLE_TARGET, {"propagation": "rprivate", "create_host_path": False}),
(module.CONFIG_TARGET, {"propagation": "rslave"}),
(module.EXECUTABLE_TARGET, {"propagation": "rslave", "create_host_path": False}),
(module.CONFIG_TARGET, {"propagation": "rshared"}),
(module.CONFIG_TARGET, {"propagation": "shared"}),
(module.CONFIG_TARGET, {"propagation": "rprivate", "create_host_path": False}),
(module.CONFIG_TARGET, {"propagation": "rslave", "create_host_path": False}),
],
)
def test_compose_rejects_noncanonical_controller_bind_propagation(compose, target, bind):
@@ -1049,7 +1050,7 @@ def test_compose_accepts_already_canonical_controller_mounts_without_duplicates(
baseline, _calls = compose
volumes = baseline["services"][module.SERVICE]["volumes"]
next(item for item in volumes if item["target"] == module.EXECUTABLE_TARGET)["bind"] = {
"propagation": "rprivate"
"propagation": "rslave"
}
volumes.extend(
[
@@ -1070,7 +1071,7 @@ def test_compose_accepts_already_canonical_controller_mounts_without_duplicates(
"source": module._expected_config_path().as_posix(),
"target": module.CONFIG_TARGET,
"read_only": True,
"bind": {"propagation": "rprivate"},
"bind": {"propagation": "rslave"},
},
]
)
@@ -1101,8 +1102,129 @@ def test_activation_and_rollback_models_both_pin_managed_bind_propagation(compos
if mount.get("target") in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}
}
assert set(managed) == {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}
assert managed[module.EXECUTABLE_TARGET]["bind"] == {"propagation": "rprivate"}
assert managed[module.CONFIG_TARGET]["bind"] == {"propagation": "rprivate"}
assert managed[module.EXECUTABLE_TARGET]["bind"] == {"propagation": "rslave"}
assert managed[module.CONFIG_TARGET]["bind"] == {"propagation": "rslave"}
def _legacy_private_compose(revision):
value = json.loads(module.rendered_model(ORIGINAL_COMPOSE, revision, IMAGE_ID))
for mount in value["services"][module.SERVICE]["volumes"]:
if mount.get("target") in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}:
mount["bind"] = {"propagation": "rprivate"}
return (json.dumps(value, sort_keys=True, indent=2) + "\n").encode()
def _projected_compose(captured):
value = json.loads(captured)
for mount in value["services"][module.SERVICE]["volumes"]:
if mount.get("target") in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}:
mount["bind"] = {"propagation": "rslave"}
assert mount["read_only"] is True
return (json.dumps(value, sort_keys=True, indent=2) + "\n").encode()
@pytest.mark.parametrize(
("rollback", "current_state", "write_expected", "error"),
[
(False, "raw_target", True, None),
(False, "projected_target", False, None),
(False, "before", True, None),
(True, "raw_target", True, None),
(True, "raw_after", True, None),
(True, "projected_after", True, None),
(True, "projected_target", False, None),
(True, "before", True, None),
(False, "unrecognized", False, "controller_volume_compose_transition_conflict"),
(True, "unrecognized", False, "controller_volume_compose_rollback_conflict"),
],
)
def test_root_compose_projects_legacy_receipts_and_bounds_retries(
compose, monkeypatch, tmp_path, rollback, current_state, write_expected, error,
):
_baseline, _calls = compose
before = b"captured-source-compose"
after = _legacy_private_compose("c" * 40)
rollback_value = _legacy_private_compose("b" * 40)
values = {"compose-after.json": after, "compose-rollback.json": rollback_value}
raw_target = rollback_value if rollback else after
projected_target = _projected_compose(raw_target)
current_by_state = {
"raw_target": raw_target,
"projected_target": projected_target,
"raw_after": after,
"projected_after": _projected_compose(after),
"before": before,
"unrecognized": b"unrecognized-compose",
}
live_file = {"value": current_by_state[current_state], "mode": 0o640}
receipt = {
"composeBeforeSha256": module._hash(before),
"composeAfterSha256": module._hash(after),
"composeRollbackSha256": module._hash(rollback_value),
}
writes = []
monkeypatch.setattr(module, "_directory", lambda *_args: 23)
monkeypatch.setattr(
module, "_file_info_at", lambda *_args: (live_file["value"], live_file["mode"])
)
monkeypatch.setattr(module, "_read_backup", lambda *_args: b'{"composeMode":416}')
monkeypatch.setattr(module.os, "close", lambda _descriptor: None)
def atomic_at(_parent, name, value, expected, *, private, mode):
assert name == "docker-compose.yml"
assert expected == live_file["value"]
assert private is False
assert mode == 0o640
writes.append(value)
live_file["value"] = value
monkeypatch.setattr(module, "_atomic_at", atomic_at)
backup = tmp_path / "backup"
if error:
with pytest.raises(module.VolumeTransitionError, match=error):
module._ensure_root_compose(backup, receipt, values, rollback=rollback)
assert writes == []
assert live_file["value"] == current_by_state[current_state]
return
module._ensure_root_compose(backup, receipt, values, rollback=rollback)
module._ensure_root_compose(backup, receipt, values, rollback=rollback)
assert len(writes) == int(write_expected)
assert live_file["value"] == projected_target
assert module._hash(after) == receipt["composeAfterSha256"]
assert module._hash(rollback_value) == receipt["composeRollbackSha256"]
assert values["compose-after.json"] == after
assert values["compose-rollback.json"] == rollback_value
@pytest.mark.parametrize("rollback", [False, True])
def test_root_compose_projected_retry_still_checks_file_mode(
compose, monkeypatch, tmp_path, rollback,
):
_baseline, _calls = compose
after = module.rendered_model(ORIGINAL_COMPOSE, "c" * 40, IMAGE_ID)
rollback_value = module.rendered_model(ORIGINAL_COMPOSE, "b" * 40, IMAGE_ID)
projected = rollback_value if rollback else after
before = b"captured-source-compose"
receipt = {
"composeBeforeSha256": module._hash(before),
"composeAfterSha256": module._hash(after),
"composeRollbackSha256": module._hash(rollback_value),
}
values = {"compose-after.json": after, "compose-rollback.json": rollback_value}
monkeypatch.setattr(module, "_directory", lambda *_args: 23)
monkeypatch.setattr(module, "_file_info_at", lambda *_args: (projected, 0o600))
monkeypatch.setattr(module, "_read_backup", lambda *_args: b'{"composeMode":416}')
monkeypatch.setattr(module.os, "close", lambda _descriptor: None)
monkeypatch.setattr(
module, "_atomic_at",
lambda *_args, **_kwargs: pytest.fail("A projected retry must not rewrite the file"),
)
with pytest.raises(module.VolumeTransitionError, match="compose_mode_invalid"):
module._ensure_root_compose(tmp_path / "backup", receipt, values, rollback=rollback)
def test_compose_allows_only_the_fixed_managed_appdata_volume_mount(compose):
@@ -2304,6 +2426,70 @@ def test_copy_diagnostic_classifies_missing_passwd_user_without_retaining_user_v
assert "fixture-user-name" not in diagnostic
@pytest.mark.parametrize('stderr,condition', [
(b'cannot create /private/fixture-secret: No space left on device', 'storage_full'),
(b'Cannot connect to the Docker daemon at unix:///private/fixture-secret', 'daemon_unavailable'),
])
def test_storage_command_diagnostics_never_retain_private_paths(stderr, condition):
assert module._command_stderr_condition(stderr, 'compose_up', max_bytes=1024) == condition
diagnostic = module._safe_command_diagnostic(('compose_up', 'exit_1', condition))
assert diagnostic == f'compose_up_exit_1_{condition}'
assert 'fixture-secret' not in diagnostic
@pytest.mark.parametrize('read_only,free_bytes', [(True, 1024**3), (False, 0)])
def test_stop_rejects_failed_storage_before_recording_intent_or_stopping(monkeypatch, read_only, free_bytes):
receipt = {'phase': 'prepared'}
monkeypatch.setattr(module, '_load_transaction', lambda _: (42, receipt, {}))
monkeypatch.setattr(module.os, 'close', lambda _: None)
monkeypatch.setattr(module.os, 'statvfs', lambda _: types.SimpleNamespace(
f_flag=1 if read_only else 0, f_bavail=free_bytes, f_frsize=1,
), raising=False)
def forbidden(*args, **kwargs):
pytest.fail('Storage rejection must happen before any persisted stop intent or container stop')
monkeypatch.setattr(module, '_set_promotion_phase', forbidden)
monkeypatch.setattr(module, '_stop_current_controller', forbidden)
code = 'controller_volume_storage_read_only' if read_only else 'controller_volume_storage_insufficient'
with pytest.raises(module.VolumeTransitionError, match=code):
module.stop_promotion(Path('/unused-backup'))
assert receipt['phase'] == 'prepared'
@pytest.mark.parametrize('native_read_only_graph', [False, True])
def test_stop_allows_healthy_storage_and_preserves_intent_before_stop(monkeypatch, native_read_only_graph):
receipt = {'phase': 'prepared'}
events = []
monkeypatch.setattr(module, '_load_transaction', lambda _: (42, receipt, {}))
monkeypatch.setattr(module.os, 'close', lambda _: None)
def filesystem(path):
events.append(('storage', path))
return types.SimpleNamespace(f_flag=1 if native_read_only_graph and path == module.DOCKER_ROOT else 0, f_bavail=1024**3, f_frsize=1)
monkeypatch.setattr(module.os, 'statvfs', filesystem, raising=False)
def persist(descriptor, value, phase):
events.append(('intent', phase))
return {**value, 'phase': phase}
monkeypatch.setattr(module, '_set_promotion_phase', persist)
monkeypatch.setattr(module, '_stop_current_controller', lambda *a, **kw: events.append(('stop', None)))
module.stop_promotion(Path('/unused-backup'))
assert events == [('storage', module.ROOT), ('storage', module.DOCKER_ROOT), ('intent', 'stopped'), ('stop', None)]
def test_stop_rejects_full_graph_even_with_native_read_only_bind(monkeypatch):
receipt = {'phase': 'prepared'}
monkeypatch.setattr(module, '_load_transaction', lambda _: (42, receipt, {}))
monkeypatch.setattr(module.os, 'close', lambda _: None)
monkeypatch.setattr(module.os, 'statvfs', lambda p: types.SimpleNamespace(
f_flag=1 if p == module.DOCKER_ROOT else 0,
f_bavail=0 if p == module.DOCKER_ROOT else 1024**3, f_frsize=1,
), raising=False)
def forbidden(*args, **kwargs):
pytest.fail('Full graph must not record a stop or interrupt the controller')
monkeypatch.setattr(module, '_set_promotion_phase', forbidden)
monkeypatch.setattr(module, '_stop_current_controller', forbidden)
with pytest.raises(module.VolumeTransitionError, match='controller_volume_storage_insufficient'):
module.stop_promotion(Path('/unused-backup'))
def test_promoted_compose_actions_use_only_stdin_and_fixed_service(compose):
baseline, calls = compose
rendered = module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)
@@ -2319,59 +2505,86 @@ def test_promoted_compose_actions_use_only_stdin_and_fixed_service(compose):
assert baseline["name"] == "gitea-deploy-runners"
@pytest.mark.parametrize("phase", ["stopped", "activated"])
def test_rollback_uses_no_dependencies_when_recreating_prior_controller(
compose, tmp_path, monkeypatch, phase,
@pytest.mark.parametrize(
("operation", "phase"),
[("activate", "stopped"), ("rollback", "stopped"), ("rollback", "activated")],
)
def test_promotion_uses_projected_persistent_compose_without_dependencies(
compose, tmp_path, monkeypatch, operation, phase,
):
_baseline, calls = compose
previous = REVISION
target = "c" * 40
previous_compose = module.rendered_model(ORIGINAL_COMPOSE, previous, IMAGE_ID)
target_compose = module.rendered_model(ORIGINAL_COMPOSE, target, IMAGE_ID)
previous_compose = _legacy_private_compose(previous)
target_compose = _legacy_private_compose(target)
receipt = {
"phase": phase,
"imageId": IMAGE_ID,
"previousRevision": previous,
"sourceRevision": target,
"composeBeforeSha256": module._hash(b"captured-source-compose"),
"composeAfterSha256": module._hash(target_compose),
"composeRollbackSha256": module._hash(previous_compose),
}
values = {
"controller-before.tar": module._release_tar_bytes(
previous,
({"controller-release.json": (b"previous release", 0o644)}, {"": 0o755}),
),
"controller-after.tar": b"unused by isolated rollback test",
"mirror-before.tar": b"unused by isolated rollback test",
"controller-before.tar": b"previous tree",
"controller-after.tar": b"target tree",
"mirror-before.tar": b"mirror snapshot",
"compose-rollback.json": previous_compose,
"compose-after.json": target_compose,
}
current = {
"Mounts": [{
"Destination": module.EXECUTABLE_TARGET,
"Source": module._expected_revision_path(previous).as_posix(),
}]
}
current = {"Mounts": [{
"Destination": module.EXECUTABLE_TARGET,
"Source": module._expected_revision_path(target).as_posix(),
}]}
live_file = {"value": target_compose, "mode": 0o640}
monkeypatch.setattr(module, "_load_transaction", lambda _backup: (1, receipt, values))
monkeypatch.setattr(module.os, "close", lambda _descriptor: None)
monkeypatch.setattr(module, "_controller_image_inspect", lambda *args, **kwargs: current)
monkeypatch.setattr(module, "_read_tar_tree", lambda _value: ({}, {"": 0o755}))
monkeypatch.setattr(module, "_verify_release_leaf", lambda *args, **kwargs: True)
monkeypatch.setattr(module, "_stop_current_controller", lambda *args, **kwargs: None)
monkeypatch.setattr(module, "_sync_controller_mirror", lambda *args, **kwargs: None)
monkeypatch.setattr(module, "_ensure_root_compose", lambda *args, **kwargs: None)
monkeypatch.setattr(module, "_stop_current_controller", lambda *args, **kwargs: None)
monkeypatch.setattr(module, "_verify_compose_image", lambda *args, **kwargs: None)
monkeypatch.setattr(module, "_verify_active_mount", lambda *args, **kwargs: None)
monkeypatch.setattr(module, "_set_promotion_phase", lambda *args, **kwargs: None)
monkeypatch.setattr(module, "_directory", lambda *_args: 23)
monkeypatch.setattr(
module, "_file_info_at", lambda *_args: (live_file["value"], live_file["mode"])
)
monkeypatch.setattr(module, "_read_backup", lambda *_args: b'{"composeMode":416}')
module.rollback_promotion(tmp_path / "backup")
def atomic_at(_parent, _name, value, expected, **_kwargs):
assert expected == live_file["value"]
live_file["value"] = value
monkeypatch.setattr(module, "_atomic_at", atomic_at)
calls.clear()
if operation == "activate":
module.activate_promotion(tmp_path / "backup")
raw_target = target_compose
else:
module.rollback_promotion(tmp_path / "backup")
raw_target = previous_compose
projected_target = _projected_compose(raw_target)
assert live_file["value"] == projected_target
up_calls = [
arguments for arguments, _data in calls
if arguments[:2] == ["docker", "--config"] and arguments[3] == "compose" and "up" in arguments
(arguments, data) for arguments, data in calls
if arguments[:2] == ["docker", "--config"]
and arguments[3] == "compose" and "up" in arguments
]
assert len(up_calls) == 1
up_index = up_calls[0].index("up")
assert up_calls[0][up_index:] == [
up_index = up_calls[0][0].index("up")
assert up_calls[0][0][up_index:] == [
"up", "-d", "--force-recreate", "--pull", "never", "--no-deps", module.SERVICE
]
assert up_calls[0][1] == projected_target
assert module._hash(target_compose) == receipt["composeAfterSha256"]
assert module._hash(previous_compose) == receipt["composeRollbackSha256"]
assert values["compose-after.json"] == target_compose
assert values["compose-rollback.json"] == previous_compose
def test_installer_has_no_shared_executable_stage_or_compose_path():
@@ -3261,3 +3474,108 @@ def test_config_snapshot_keeps_appops_legacy_state_contract_closed(damage):
entries["appops-legacy-upgrade/unraid-appops-gateway.json"] = (b"{}", 0o644)
with pytest.raises(module.VolumeTransitionError):
module._config_tree(entries, directories)
@pytest.mark.parametrize("action", ["up", "stop"])
def test_legacy_receipt_projection_preserves_evidence_and_readonly_mounts(compose, action):
_baseline, calls = compose
value = json.loads(module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID))
for mount in value["services"][module.SERVICE]["volumes"]:
if mount.get("target") in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}:
mount["bind"] = {"propagation": "rprivate"}
captured = json.dumps(value).encode()
original_hash = module._hash(captured)
with pytest.raises(module.VolumeTransitionError):
module._bound_model(captured)
module.compose_action(captured, action)
projected = json.loads(calls[-1][1])
for mount in projected["services"][module.SERVICE]["volumes"]:
if mount.get("target") in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}:
assert mount["read_only"] is True
assert mount["bind"] == {"propagation": "rslave"}
mount["bind"] = {"propagation": "rprivate"}
assert projected == value
assert module._hash(captured) == original_hash
@pytest.mark.parametrize("damage", ["writable", "shared", "extra_option", "foreign_source"])
def test_legacy_receipt_projection_rejects_unsafe_mount_before_docker(compose, damage):
_baseline, calls = compose
value = json.loads(module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID))
mount = next(x for x in value["services"][module.SERVICE]["volumes"]
if x.get("target") == module.EXECUTABLE_TARGET)
mount["bind"] = {"propagation": "rprivate"}
if damage == "writable":
mount["read_only"] = False
elif damage == "shared":
mount["bind"]["propagation"] = "rshared"
elif damage == "extra_option":
mount["bind"]["create_host_path"] = True
else:
mount["source"] = "/tmp/untrusted-controller"
calls.clear()
with pytest.raises(module.VolumeTransitionError):
module.compose_action(json.dumps(value).encode(), "up")
assert calls == []
@pytest.mark.parametrize("phase", ["prepared", "stopped", "activated", "verified"])
@pytest.mark.parametrize("failure", ["read_only", "full"])
def test_forward_stop_rechecks_storage_after_intent_or_on_resume(monkeypatch, phase, failure):
receipt = {"phase": phase, "imageId": IMAGE_ID,
"previousRevision": OLD_REVISION, "sourceRevision": REVISION}
persisted = phase != "prepared"
events = []
monkeypatch.setattr(module, "_load_transaction", lambda _: (42, receipt, {
"compose-rollback.json": b"old", "compose-after.json": b"new"}))
monkeypatch.setattr(module.os, "close", lambda _: None)
def persist(_fd, value, new_phase):
nonlocal persisted
persisted = True
return {**value, "phase": new_phase}
monkeypatch.setattr(module, "_set_promotion_phase", persist)
def filesystem(_path):
return types.SimpleNamespace(
f_flag=1 if persisted and failure == "read_only" else 0,
f_bavail=0 if persisted and failure == "full" else 1024**3,
f_frsize=1)
monkeypatch.setattr(module.os, "statvfs", filesystem, raising=False)
monkeypatch.setattr(module, "_controller_image_inspect", lambda *_a, **_kw: {
"State": {"Running": True}, "Mounts": [{"Destination": module.EXECUTABLE_TARGET,
"Source": module._expected_revision_path(OLD_REVISION).as_posix()}]})
monkeypatch.setattr(module, "compose_action", lambda *args: events.append(args))
code = "controller_volume_storage_read_only" if failure == "read_only" else "controller_volume_storage_insufficient"
with pytest.raises(module.VolumeTransitionError, match=code):
module.stop_promotion(Path("/unused-backup"))
assert events == []
def test_resumed_forward_stop_of_stopped_controller_needs_no_storage(monkeypatch):
receipt = {"phase": "stopped", "imageId": IMAGE_ID,
"previousRevision": OLD_REVISION, "sourceRevision": REVISION}
monkeypatch.setattr(module, "_load_transaction", lambda _: (42, receipt, {}))
monkeypatch.setattr(module.os, "close", lambda _: None)
monkeypatch.setattr(module, "_controller_image_inspect", lambda *_a, **_kw: {
"State": {"Running": False}, "Mounts": [{"Destination": module.EXECUTABLE_TARGET,
"Source": module._expected_revision_path(OLD_REVISION).as_posix()}]})
monkeypatch.setattr(module, "_require_writable_promotion_storage",
lambda: pytest.fail("Already stopped recovery must remain possible"))
monkeypatch.setattr(module, "compose_action", lambda *_: pytest.fail("No stop needed"))
module.stop_promotion(Path("/unused-backup"))
def test_rollback_stop_remains_available_without_forward_storage_gate(monkeypatch):
receipt = {"phase": "stopped", "imageId": IMAGE_ID,
"previousRevision": OLD_REVISION, "sourceRevision": REVISION}
events = []
monkeypatch.setattr(module, "_load_transaction", lambda _: (42, receipt, {
"compose-rollback.json": b"old", "compose-after.json": b"new"}))
monkeypatch.setattr(module.os, "close", lambda _: None)
monkeypatch.setattr(module, "_controller_image_inspect", lambda *_a, **_kw: {
"State": {"Running": True}, "Mounts": [{"Destination": module.EXECUTABLE_TARGET,
"Source": module._expected_revision_path(REVISION).as_posix()}]})
monkeypatch.setattr(module, "_require_writable_promotion_storage",
lambda: pytest.fail("Rollback must not inherit forward capacity gate"))
monkeypatch.setattr(module, "compose_action", lambda *args: events.append(args))
module.rollback_promotion(Path("/unused-backup"), stop_only=True)
assert events == [(b"new", "stop")]