Publish curated Unraid-AppOps-Gateway source from 0f89e1506065c5365c48968b1d7845d666a18be8
This commit is contained in:
1 parent
3eed2d6657
commit
a0d1a1224a
8 files changed
+152
-31
No files matched your search
@@ -1,3 +1,3 @@
|
||||
# Public source export
|
||||
|
||||
This source snapshot was generated from the private canonical repository at revision `25057d17d4995d4bd407ff0516ef69ddaec878c6`. It contains no private Git history or operational evidence. Changes are published from the canonical repository.
|
||||
This source snapshot was generated from the private canonical repository at revision `0f89e1506065c5365c48968b1d7845d666a18be8`. It contains no private Git history or operational evidence. Changes are published from the canonical repository.
|
||||
+10
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"sourceRevision": "25057d17d4995d4bd407ff0516ef69ddaec878c6",
|
||||
"sourceRevision": "0f89e1506065c5365c48968b1d7845d666a18be8",
|
||||
"files": [
|
||||
{
|
||||
"path": ".dockerignore",
|
||||
@@ -524,7 +524,7 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/controller_artifact_helper_spec.py",
|
||||
"sha256": "232048b69a06e5fe4edc1c5fab27c3032bdd829bbbe9589b7d47b1a288207ca8",
|
||||
"sha256": "e345e51a81eb483ffe8532592deb197296bd28db99e1e5bc358c590a59f1b94d",
|
||||
"bytes": 6029
|
||||
},
|
||||
{
|
||||
@@ -949,7 +949,7 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_controller_artifact_bootstrap.py",
|
||||
"sha256": "46ceb18a80b1f413da98325bea69c80422057354fb54569cc5563fccfffdda64",
|
||||
"sha256": "6774b7fdf9fd91c463661970512b93027af93979fd4b6d61d89476f0465cc48b",
|
||||
"bytes": 82644
|
||||
},
|
||||
{
|
||||
@@ -964,7 +964,7 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_controller_artifact_promoter.py",
|
||||
"sha256": "284389dc63da8305927e80bf4ed4120e1acac6b0eb86b2b29f5cbbc360fa3778",
|
||||
"sha256": "1a274fb06503ce5568e2096898e1a98ded10dee333f49bd45060bbc82adf00cb",
|
||||
"bytes": 7570
|
||||
},
|
||||
{
|
||||
@@ -989,8 +989,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_controller_hub_release_volume.py",
|
||||
"sha256": "e13f1f01799fe74821eab9622d7eb4cf9add65196a67bde386f1c11231e04c5d",
|
||||
"bytes": 161342
|
||||
"sha256": "c3de3b47673a5fa365bf20aedc020a2ca733804adc9cd05d15b26aca2a1a2b05",
|
||||
"bytes": 161657
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_controller_root_procedure_contract.py",
|
||||
@@ -1624,8 +1624,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_fleet_specialize.py",
|
||||
"sha256": "9de45904f4a4fd254d5e3b4c502b3d2c235f2ce220fd8f122a5ed840388b2eb1",
|
||||
"bytes": 113743
|
||||
"sha256": "6771e7ae22ddb0d39e19272d311f9c77b898273c7106eef02ba0c76fc6e48873",
|
||||
"bytes": 119270
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_fleet_v2_execution_surface.py",
|
||||
@@ -1639,8 +1639,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_resource_envelope.py",
|
||||
"sha256": "a0e469bc980dbec76cedda9a1ae5011fc1444d49ffed4c067268a10f1905a5a1",
|
||||
"bytes": 40504
|
||||
"sha256": "9bb731508ac3cc4d36ba5eae7839c0f73b411880b92a45118dfaa4b62c4e019f",
|
||||
"bytes": 40972
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_scheduler_liveness_recovery.py",
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any
|
||||
|
||||
SOURCE_REPOSITORY = "jens/unraid-appops-gateway"
|
||||
HELPER_NAME = "itworx-controller-artifact-promoter-modern"
|
||||
CONTROLLER_VERSION = "2.1.18"
|
||||
CONTROLLER_VERSION = "2.1.19"
|
||||
HELPER_LABEL = "itworx.controller.promoter"
|
||||
_REVISION = re.compile(r"^[0-9a-f]{40}$")
|
||||
_IMAGE_ID = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
|
||||
@@ -589,7 +589,7 @@ def _health(
|
||||
*,
|
||||
revision: str = REVISION,
|
||||
tree: str = TREE,
|
||||
version: str = "2.1.18",
|
||||
version: str = "2.1.19",
|
||||
artifact_sha256: str = "1" * 64,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -1347,7 +1347,7 @@ def test_current_214_baseline_is_verified_and_bound_to_new_helper():
|
||||
"itworx.controller.promoter": "true",
|
||||
"itworx.source.repository": "jens/unraid-appops-gateway",
|
||||
"itworx.source.revision": REVISION,
|
||||
"itworx.controller.version": "2.1.18",
|
||||
"itworx.controller.version": "2.1.19",
|
||||
"itworx.controller.bootstrap.target.id": MODERN_CONTROLLER_CONTAINER_ID,
|
||||
"itworx.controller.bootstrap.target.image": CONTROLLER_IMAGE_ID,
|
||||
"itworx.controller.bootstrap.target.revision": MODERN_CONTROLLER_REVISION,
|
||||
@@ -1826,7 +1826,7 @@ def test_successful_child_requires_exact_source_tree_version_and_health():
|
||||
|
||||
assert result["status"] == "promoted"
|
||||
assert result["revision"] == REVISION
|
||||
assert result["controllerVersion"] == "2.1.18"
|
||||
assert result["controllerVersion"] == "2.1.19"
|
||||
assert result["artifactSha256"] == "1" * 64
|
||||
assert docker.started == [MODERN_HELPER_ID]
|
||||
assert docker.created[0]["Image"] == CONTROLLER_IMAGE_ID
|
||||
@@ -2086,32 +2086,32 @@ def test_modern_upgrade_path_still_requires_private_archive_and_image_id(
|
||||
def test_current_helper_version_is_distinct_from_fixed_controller_baselines():
|
||||
source = (ROOT / "backend/app/services/controller_artifact_helper_spec.py").read_bytes()
|
||||
namespace = bootstrap._safe_modern_spec_namespace(source)
|
||||
assert namespace["CONTROLLER_VERSION"] == "2.1.18"
|
||||
assert bootstrap._verified_historical_helper_version(source) == "2.1.18"
|
||||
assert namespace["CONTROLLER_VERSION"] == "2.1.19"
|
||||
assert bootstrap._verified_historical_helper_version(source) == "2.1.19"
|
||||
assert [baseline.version for baseline in bootstrap.CONTROLLER_BASELINES] == ["2.1.12", "2.1.14"]
|
||||
with pytest.raises(bootstrap.BootstrapError, match="controller_promoter_modern_spec_identity_invalid"):
|
||||
bootstrap._safe_modern_spec_namespace(RETIRED_MODERN_HELPER_SOURCE.encode("utf-8"))
|
||||
assert bootstrap._verified_historical_helper_version(RETIRED_MODERN_HELPER_SOURCE.encode("utf-8")) == "2.1.14"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", ["2.1.16", "2.1.19", "9.9.9"])
|
||||
@pytest.mark.parametrize("version", ["2.1.16", "2.1.20", "9.9.9"])
|
||||
def test_current_and_historical_helper_reject_unreviewed_target_versions(version):
|
||||
source = (ROOT / "backend/app/services/controller_artifact_helper_spec.py").read_bytes()
|
||||
foreign = source.replace(b'CONTROLLER_VERSION = "2.1.18"', f'CONTROLLER_VERSION = "{version}"'.encode())
|
||||
foreign = source.replace(b'CONTROLLER_VERSION = "2.1.19"', f'CONTROLLER_VERSION = "{version}"'.encode())
|
||||
with pytest.raises(bootstrap.BootstrapError, match="controller_promoter_modern_spec_identity_invalid"):
|
||||
bootstrap._safe_modern_spec_namespace(foreign)
|
||||
with pytest.raises(bootstrap.BootstrapError, match="controller_promoter_modern_spec_identity_invalid"):
|
||||
bootstrap._verified_historical_helper_version(foreign)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", ["2.1.14", "2.1.16", "2.1.19"])
|
||||
@pytest.mark.parametrize("version", ["2.1.14", "2.1.16", "2.1.20"])
|
||||
def test_promoted_health_cannot_substitute_a_baseline_or_unknown_version(version):
|
||||
assert not bootstrap._health_matches(_health(version=version), REVISION, TREE)
|
||||
|
||||
|
||||
def test_previous_reviewed_helper_is_historical_only():
|
||||
source = (ROOT / "backend/app/services/controller_artifact_helper_spec.py").read_bytes()
|
||||
prior = source.replace(b'CONTROLLER_VERSION = "2.1.18"', b'CONTROLLER_VERSION = "2.1.17"')
|
||||
prior = source.replace(b'CONTROLLER_VERSION = "2.1.19"', b'CONTROLLER_VERSION = "2.1.18"')
|
||||
with pytest.raises(bootstrap.BootstrapError, match="controller_promoter_modern_spec_identity_invalid"):
|
||||
bootstrap._safe_modern_spec_namespace(prior)
|
||||
assert bootstrap._verified_historical_helper_version(prior) == "2.1.17"
|
||||
assert bootstrap._verified_historical_helper_version(prior) == "2.1.18"
|
||||
@@ -46,7 +46,7 @@ def test_controller_promoter_helper_spec_is_fixed_and_bounded():
|
||||
spec = helper_container_spec(revision=revision, image_id=image_id)
|
||||
|
||||
assert HELPER_NAME == "itworx-controller-artifact-promoter-modern"
|
||||
assert CONTROLLER_VERSION == "2.1.18"
|
||||
assert CONTROLLER_VERSION == "2.1.19"
|
||||
assert re.fullmatch(r"2\.1\.\d+", CONTROLLER_VERSION)
|
||||
assert spec["Image"] == image_id
|
||||
assert spec["HostConfig"]["Privileged"] is False
|
||||
@@ -79,7 +79,7 @@ def test_controller_promoter_helper_spec_is_fixed_and_bounded():
|
||||
assert revision in command
|
||||
assert "--image-id" in command
|
||||
assert CONTROLLER_VERSION in command
|
||||
assert "version='2.1.18'" in command
|
||||
assert "version='2.1.19'" in command
|
||||
assert "/gitea-repositories/jens/unraid-appops-gateway.git" in command
|
||||
archive_start = command.index('git --git-dir="$repo" archive')
|
||||
archive_end = command.index('> "$work/controller.tar"', archive_start)
|
||||
@@ -110,7 +110,7 @@ def test_controller_promoter_bootstrap_child_spec_binds_legacy_target_identity()
|
||||
"itworx.controller.promoter": "true",
|
||||
"itworx.source.repository": "jens/unraid-appops-gateway",
|
||||
"itworx.source.revision": revision,
|
||||
"itworx.controller.version": "2.1.18",
|
||||
"itworx.controller.version": "2.1.19",
|
||||
"itworx.controller.bootstrap.target.id": target_id,
|
||||
"itworx.controller.bootstrap.target.image": image_id,
|
||||
"itworx.controller.bootstrap.target.revision": target_revision,
|
||||
|
||||
@@ -34,8 +34,9 @@ def compose_config_root(monkeypatch, tmp_path):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ["config", "stop", "up"])
|
||||
@pytest.mark.parametrize("source_directory", [False, True])
|
||||
def test_compose_uses_private_ephemeral_cli_config_with_readonly_root(
|
||||
monkeypatch, tmp_path, operation,
|
||||
monkeypatch, tmp_path, operation, source_directory,
|
||||
):
|
||||
directories = []
|
||||
|
||||
@@ -57,12 +58,15 @@ def test_compose_uses_private_ephemeral_cli_config_with_readonly_root(
|
||||
assert arguments[-1] == operation
|
||||
assert arguments[arguments.index("--env-file") + 1] == "/dev/null"
|
||||
assert arguments[arguments.index("-f") + 1] == "-"
|
||||
assert ("--profile" in arguments) is (source_directory and operation == "config")
|
||||
if "--profile" in arguments:
|
||||
assert arguments[arguments.index("--profile") + 1] == "runner"
|
||||
(directory / "generated-client-state").write_bytes(b"private-client-state")
|
||||
return b"ok"
|
||||
|
||||
monkeypatch.setattr(module, "_run", run)
|
||||
for _ in range(2):
|
||||
assert module._compose([operation], b"captured-private-compose") == b"ok"
|
||||
assert module._compose([operation], b"captured-private-compose", source_directory=source_directory) == b"ok"
|
||||
assert directories[0] != directories[1]
|
||||
assert all(not directory.exists() for directory in directories)
|
||||
|
||||
|
||||
@@ -13,6 +13,113 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller_only_source(specialize, tmp_path, monkeypatch):
|
||||
image = "sha256:" + "a" * 64
|
||||
code = f"{specialize.RELEASE_VOLUME_HOST_ROOT}/controller-artifacts/{'b' * 40}"
|
||||
model = {"name": specialize.PROJECT, "services": {"deploy-control": {
|
||||
"container_name": specialize.CONTROLLER_CONTAINER, "image": image,
|
||||
"volumes": [{"type": "bind", "source": code, "target": "/opt/gitea-deploy",
|
||||
"read_only": True, "bind": {"propagation": "rslave"}}],
|
||||
}}}
|
||||
compose = tmp_path / "docker-compose.yml"
|
||||
archive_dir = tmp_path / "backups" / "controller-v2-fixed"
|
||||
archive_dir.mkdir(parents=True, mode=0o700)
|
||||
archive_dir.parent.chmod(0o700)
|
||||
archive = archive_dir / "compose-before.bin"
|
||||
archived = b"reviewed complete legacy v1 source"
|
||||
archive.write_bytes(archived)
|
||||
archive.chmod(0o600)
|
||||
monkeypatch.setattr(specialize, "ROOT", tmp_path)
|
||||
monkeypatch.setattr(specialize, "COMPOSE_PATH", compose)
|
||||
monkeypatch.setattr(specialize, "LEGACY_V1_COMPOSE_BACKUP", archive)
|
||||
monkeypatch.setattr(specialize, "_KNOWN_V1_COMPOSE_SHA256", hashlib.sha256(archived).hexdigest())
|
||||
monkeypatch.setattr(specialize, "_controller_runtime_identity", lambda: (image, code))
|
||||
return model, compose, archive, archived
|
||||
|
||||
|
||||
def test_controller_only_source_uses_fixed_pinned_archive_without_rewriting_live_compose(
|
||||
specialize, controller_only_source,
|
||||
):
|
||||
model, compose, _archive, archived = controller_only_source
|
||||
current = json.dumps(model).encode()
|
||||
compose.write_bytes(current)
|
||||
result, current_sha = specialize._read_fleet_base_compose()
|
||||
assert result == archived
|
||||
assert current_sha == hashlib.sha256(current).hexdigest()
|
||||
assert compose.read_bytes() == current
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", ["project", "image", "container", "code", "writable", "shared", "extra_code"])
|
||||
def test_controller_only_source_rejects_identity_and_code_binding_drift(
|
||||
specialize, controller_only_source, mutation,
|
||||
):
|
||||
model, compose, _archive, _archived = controller_only_source
|
||||
controller = model["services"]["deploy-control"]
|
||||
mount = controller["volumes"][0]
|
||||
if mutation == "project":
|
||||
model["name"] = "other"
|
||||
elif mutation == "image":
|
||||
controller["image"] = "sha256:" + "c" * 64
|
||||
elif mutation == "container":
|
||||
controller["container_name"] = "other"
|
||||
elif mutation == "code":
|
||||
mount["source"] = "/mnt/shared/code"
|
||||
elif mutation == "writable":
|
||||
mount["read_only"] = False
|
||||
elif mutation == "shared":
|
||||
mount["bind"]["propagation"] = "rshared"
|
||||
else:
|
||||
controller["volumes"].append(copy.deepcopy(mount))
|
||||
compose.write_text(json.dumps(model))
|
||||
with pytest.raises(specialize.RunnerFleetSpecializeError, match="controller_compose_untrusted"):
|
||||
specialize._read_fleet_base_compose()
|
||||
|
||||
|
||||
def test_controller_only_source_rejects_modified_fixed_archive(specialize, controller_only_source):
|
||||
model, compose, archive, _archived = controller_only_source
|
||||
compose.write_text(json.dumps(model))
|
||||
archive.write_bytes(b"different legacy source")
|
||||
with pytest.raises(specialize.RunnerFleetSpecializeError, match="legacy_backup_changed"):
|
||||
specialize._read_fleet_base_compose()
|
||||
|
||||
|
||||
def test_legacy_live_source_does_not_open_promotion_backup(specialize, monkeypatch):
|
||||
current = b"services:\n runner: {}\n"
|
||||
monkeypatch.setattr(specialize, "_read_source_file", lambda *_args, **_kwargs: current)
|
||||
monkeypatch.setattr(specialize, "_trusted_private_file", lambda *_args, **_kwargs: pytest.fail("archive must remain unopened"))
|
||||
assert specialize._read_fleet_base_compose() == (current, None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("separate_controller_hash", [False, True])
|
||||
def test_controller_compose_cas_blocks_rollback_before_any_lifecycle_action(
|
||||
specialize, controller_only_source, monkeypatch, separate_controller_hash,
|
||||
):
|
||||
model, compose, _archive, _archived = controller_only_source
|
||||
compose.write_text(json.dumps(model))
|
||||
original_sha = hashlib.sha256(compose.read_bytes()).hexdigest()
|
||||
journal = {"baseComposeSha256": original_sha}
|
||||
if separate_controller_hash:
|
||||
journal["controllerComposeSha256"] = original_sha
|
||||
compose.write_bytes(compose.read_bytes() + b"\n")
|
||||
monkeypatch.setattr(specialize, "_guard_state_unchanged", lambda *_args: pytest.fail("must reject before lifecycle"))
|
||||
assert specialize._rollback_before_activation(journal) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reviewed,user,image,expected", [
|
||||
(True, None, True, "rootless"), (False, None, True, None),
|
||||
(True, "root", True, "root"), (True, None, False, None),
|
||||
])
|
||||
def test_only_byte_pinned_v1_pinned_image_default_user_is_normalized(
|
||||
specialize, monkeypatch, reviewed, user, image, expected,
|
||||
):
|
||||
source = b"reviewed v1 source" if reviewed else b"different source"
|
||||
monkeypatch.setattr(specialize, "_KNOWN_V1_COMPOSE_SHA256", hashlib.sha256(b"reviewed v1 source").hexdigest())
|
||||
model = {"services": {"runner-dind": {"image": specialize._DIND_IMAGE_REFERENCE if image else "unknown", "user": user}}}
|
||||
monkeypatch.setattr(specialize, "_run_compose", lambda *_args: json.dumps(model))
|
||||
assert specialize._compose_model(source)["services"]["runner-dind"]["user"] == expected
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BROKER = ROOT / "ops" / "gitea-deploy-broker"
|
||||
SPECIALIZE_PATH = BROKER / "runner_fleet_specialize.py"
|
||||
@@ -2145,6 +2252,10 @@ def test_migration_failure_restores_captured_dind_state_before_cgroup_cleanup(
|
||||
|
||||
base_bytes = json.dumps(base_model, sort_keys=True, separators=(",", ":")).encode()
|
||||
specialized_bytes = json.dumps(specialized_model, sort_keys=True, separators=(",", ":")).encode()
|
||||
live_compose = tmp_path / "docker-compose.yml"
|
||||
live_compose.write_bytes(base_bytes)
|
||||
monkeypatch.setattr(specialize, "COMPOSE_PATH", live_compose)
|
||||
journal["baseComposeSha256"] = hashlib.sha256(base_bytes).hexdigest()
|
||||
monkeypatch.setattr(specialize, "_guard_state_unchanged", lambda _journal: True)
|
||||
monkeypatch.setattr(
|
||||
specialize,
|
||||
|
||||
@@ -478,7 +478,7 @@ def test_host_probe_contract_is_bounded_and_never_mounts_host_proc_or_docker_soc
|
||||
assert "target=/host-cgroup" in mount
|
||||
assert "readonly" in mount
|
||||
assert mounts[1] == (
|
||||
f"type=bind,source={code_source},target=/opt/gitea-deploy,readonly,bind-propagation=rprivate"
|
||||
f"type=bind,source={code_source},target=/opt/gitea-deploy,readonly,bind-propagation=rslave"
|
||||
)
|
||||
assert not any("/proc" in item or "docker.sock" in item for item in args)
|
||||
assert args[-3:] == [image_id, "/opt/gitea-deploy/runner_resource_envelope.py", "--host-probe"]
|
||||
@@ -528,7 +528,7 @@ def test_host_probe_inspection_rejects_namespace_mount_and_resource_drift():
|
||||
"Source": code_source,
|
||||
"Target": "/opt/gitea-deploy",
|
||||
"ReadOnly": True,
|
||||
"BindOptions": {"Propagation": "rprivate"},
|
||||
"BindOptions": {"Propagation": "rslave"},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -547,7 +547,7 @@ def test_host_probe_inspection_rejects_namespace_mount_and_resource_drift():
|
||||
"Destination": "/opt/gitea-deploy",
|
||||
"Mode": "",
|
||||
"RW": False,
|
||||
"Propagation": "rprivate",
|
||||
"Propagation": "rslave",
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -585,6 +585,12 @@ def test_host_probe_inspection_rejects_namespace_mount_and_resource_drift():
|
||||
),
|
||||
lambda item: item["Mounts"][0].update(RW=True),
|
||||
lambda item: item["Mounts"][1].update(RW=True),
|
||||
lambda item: item["Mounts"][0].update(Propagation="rslave"),
|
||||
lambda item: item["Mounts"][1].update(Propagation="rprivate"),
|
||||
lambda item: item["Mounts"][1].update(Propagation="rshared"),
|
||||
lambda item: item["Mounts"][1].update(Propagation="slave"),
|
||||
lambda item: item["HostConfig"]["Mounts"][0]["BindOptions"].update(Propagation="rslave"),
|
||||
lambda item: item["HostConfig"]["Mounts"][1]["BindOptions"].update(Propagation="rprivate"),
|
||||
lambda item: item["Mounts"][0].update(Mode="ro"),
|
||||
lambda item: item["Mounts"][1].update(Mode="ro"),
|
||||
lambda item: item["Mounts"][1].update(Source="/mnt/shared/code"),
|
||||
@@ -734,7 +740,7 @@ def test_host_probe_rw_modes_are_fixed_and_limited_to_ensure_or_rollback():
|
||||
)
|
||||
assert all(
|
||||
code_mount
|
||||
== f"type=bind,source={code_source},target=/opt/gitea-deploy,readonly,bind-propagation=rprivate"
|
||||
== f"type=bind,source={code_source},target=/opt/gitea-deploy,readonly,bind-propagation=rslave"
|
||||
for code_mount in (snapshot_code_mount, ensure_code_mount, rollback_code_mount, family_code_mount)
|
||||
)
|
||||
for args in (snapshot, ensure, rollback, family):
|
||||
|
||||
Reference in new issue
Block a user