Publish curated Unraid-AppOps-Gateway source from aaf7d11821a86be65c91fead97a6a4758c4fbacd
This commit is contained in:
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")]
|
||||
Reference in new issue
Block a user