refactor: cross-platform tool downloads, configurable infra dir and ACME email
- Make tool downloads platform-aware (darwin/linux, arm64/amd64) - Add buildctl to bundled tools - Add get_infra_dir() with config fallback for REPO_ROOT resolution - Add ACME email to sunbeam config (set/get) - Add REGISTRY_HOST_IP substitution in kustomize builds - Update Kratos admin identity schema to employee - Fix logs command to use production tunnel and context
This commit is contained in:
@@ -12,7 +12,8 @@ from sunbeam.tools import run_tool, CACHE_DIR
|
|||||||
from sunbeam.output import step, ok, warn, die
|
from sunbeam.output import step, ok, warn, die
|
||||||
|
|
||||||
LIMA_VM = "sunbeam"
|
LIMA_VM = "sunbeam"
|
||||||
SECRETS_DIR = Path(__file__).parents[3] / "infrastructure" / "secrets" / "local"
|
from sunbeam.config import get_infra_dir as _get_infra_dir
|
||||||
|
SECRETS_DIR = _get_infra_dir() / "secrets" / "local"
|
||||||
|
|
||||||
GITEA_ADMIN_USER = "gitea_admin"
|
GITEA_ADMIN_USER = "gitea_admin"
|
||||||
|
|
||||||
|
|||||||
@@ -11,15 +11,18 @@ CONFIG_PATH = Path.home() / ".sunbeam.json"
|
|||||||
class SunbeamConfig:
|
class SunbeamConfig:
|
||||||
"""Sunbeam configuration with production host and infrastructure directory."""
|
"""Sunbeam configuration with production host and infrastructure directory."""
|
||||||
|
|
||||||
def __init__(self, production_host: str = "", infra_directory: str = ""):
|
def __init__(self, production_host: str = "", infra_directory: str = "",
|
||||||
|
acme_email: str = ""):
|
||||||
self.production_host = production_host
|
self.production_host = production_host
|
||||||
self.infra_directory = infra_directory
|
self.infra_directory = infra_directory
|
||||||
|
self.acme_email = acme_email
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Convert configuration to dictionary for JSON serialization."""
|
"""Convert configuration to dictionary for JSON serialization."""
|
||||||
return {
|
return {
|
||||||
"production_host": self.production_host,
|
"production_host": self.production_host,
|
||||||
"infra_directory": self.infra_directory,
|
"infra_directory": self.infra_directory,
|
||||||
|
"acme_email": self.acme_email,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -28,6 +31,7 @@ class SunbeamConfig:
|
|||||||
return cls(
|
return cls(
|
||||||
production_host=data.get("production_host", ""),
|
production_host=data.get("production_host", ""),
|
||||||
infra_directory=data.get("infra_directory", ""),
|
infra_directory=data.get("infra_directory", ""),
|
||||||
|
acme_email=data.get("acme_email", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -71,3 +75,22 @@ def get_infra_directory() -> str:
|
|||||||
"""Get infrastructure directory from config."""
|
"""Get infrastructure directory from config."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
return config.infra_directory
|
return config.infra_directory
|
||||||
|
|
||||||
|
|
||||||
|
def get_infra_dir() -> "Path":
|
||||||
|
"""Infrastructure manifests directory as a Path.
|
||||||
|
|
||||||
|
Prefers the configured infra_directory; falls back to the package-relative
|
||||||
|
path (works when running from the development checkout).
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
configured = load_config().infra_directory
|
||||||
|
if configured:
|
||||||
|
return Path(configured)
|
||||||
|
# Dev fallback: cli/sunbeam/config.py → parents[0]=cli/sunbeam, [1]=cli, [2]=monorepo root
|
||||||
|
return Path(__file__).resolve().parents[2] / "infrastructure"
|
||||||
|
|
||||||
|
|
||||||
|
def get_repo_root() -> "Path":
|
||||||
|
"""Monorepo root directory (parent of the infrastructure directory)."""
|
||||||
|
return get_infra_dir().parent
|
||||||
|
|||||||
@@ -645,52 +645,11 @@ def _build_kratos_admin(push: bool = False, deploy: bool = False):
|
|||||||
|
|
||||||
step(f"Building kratos-admin-ui -> {image} ...")
|
step(f"Building kratos-admin-ui -> {image} ...")
|
||||||
|
|
||||||
if env.is_prod:
|
_build_image(
|
||||||
# Cross-compile Deno for x86_64 and package into a minimal image.
|
env, image,
|
||||||
if not shutil.which("deno"):
|
kratos_admin_dir / "Dockerfile", kratos_admin_dir,
|
||||||
die("deno not found — install Deno: https://deno.land/")
|
push=push,
|
||||||
if not shutil.which("npm"):
|
)
|
||||||
die("npm not found — install Node.js")
|
|
||||||
|
|
||||||
ok("Building UI assets (npm run build)...")
|
|
||||||
_run(["npm", "run", "build"], cwd=str(kratos_admin_dir / "ui"))
|
|
||||||
|
|
||||||
ok("Cross-compiling Deno binary for x86_64-linux-gnu...")
|
|
||||||
_run([
|
|
||||||
"deno", "compile",
|
|
||||||
"--target", "x86_64-unknown-linux-gnu",
|
|
||||||
"--allow-net", "--allow-read", "--allow-env",
|
|
||||||
"--include", "ui/dist",
|
|
||||||
"-o", "kratos-admin-x86_64",
|
|
||||||
"main.ts",
|
|
||||||
], cwd=str(kratos_admin_dir))
|
|
||||||
|
|
||||||
bin_path = kratos_admin_dir / "kratos-admin-x86_64"
|
|
||||||
if not bin_path.exists():
|
|
||||||
die("Deno cross-compilation produced no binary")
|
|
||||||
|
|
||||||
pkg_dir = Path(tempfile.mkdtemp(prefix="kratos-admin-pkg-"))
|
|
||||||
shutil.copy2(str(bin_path), str(pkg_dir / "kratos-admin"))
|
|
||||||
dockerfile = pkg_dir / "Dockerfile"
|
|
||||||
dockerfile.write_text(
|
|
||||||
"FROM gcr.io/distroless/cc-debian12:nonroot\n"
|
|
||||||
"WORKDIR /app\n"
|
|
||||||
"COPY kratos-admin ./\n"
|
|
||||||
"EXPOSE 3000\n"
|
|
||||||
'ENTRYPOINT ["/app/kratos-admin"]\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
_build_image(env, image, dockerfile, pkg_dir, push=push)
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(str(pkg_dir), ignore_errors=True)
|
|
||||||
else:
|
|
||||||
# Local: buildkitd handles the full Dockerfile build
|
|
||||||
_build_image(
|
|
||||||
env, image,
|
|
||||||
kratos_admin_dir / "Dockerfile", kratos_admin_dir,
|
|
||||||
push=push,
|
|
||||||
)
|
|
||||||
|
|
||||||
if deploy:
|
if deploy:
|
||||||
_deploy_rollout(env, ["kratos-admin-ui"], "ory", timeout="120s")
|
_deploy_rollout(env, ["kratos-admin-ui"], "ory", timeout="120s")
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ def cmd_bao(bao_args: list[str]) -> int:
|
|||||||
|
|
||||||
def kustomize_build(overlay: Path, domain: str, email: str = "") -> str:
|
def kustomize_build(overlay: Path, domain: str, email: str = "") -> str:
|
||||||
"""Run kustomize build --enable-helm and apply domain/email substitution."""
|
"""Run kustomize build --enable-helm and apply domain/email substitution."""
|
||||||
|
import socket as _socket
|
||||||
r = run_tool(
|
r = run_tool(
|
||||||
"kustomize", "build", "--enable-helm", str(overlay),
|
"kustomize", "build", "--enable-helm", str(overlay),
|
||||||
capture_output=True, text=True, check=True,
|
capture_output=True, text=True, check=True,
|
||||||
@@ -235,5 +236,22 @@ def kustomize_build(overlay: Path, domain: str, email: str = "") -> str:
|
|||||||
text = domain_replace(text, domain)
|
text = domain_replace(text, domain)
|
||||||
if email:
|
if email:
|
||||||
text = text.replace("ACME_EMAIL", email)
|
text = text.replace("ACME_EMAIL", email)
|
||||||
|
if "REGISTRY_HOST_IP" in text:
|
||||||
|
registry_ip = ""
|
||||||
|
try:
|
||||||
|
registry_ip = _socket.gethostbyname(f"src.{domain}")
|
||||||
|
except _socket.gaierror:
|
||||||
|
pass
|
||||||
|
if not registry_ip:
|
||||||
|
# DNS not resolvable locally (VPN, split-horizon, etc.) — derive IP from SSH host config
|
||||||
|
from sunbeam.config import get_production_host as _get_host
|
||||||
|
ssh_host = _get_host()
|
||||||
|
# ssh_host may be "user@host" or just "host"
|
||||||
|
raw = ssh_host.split("@")[-1].split(":")[0]
|
||||||
|
try:
|
||||||
|
registry_ip = _socket.gethostbyname(raw)
|
||||||
|
except _socket.gaierror:
|
||||||
|
registry_ip = raw # raw is already an IP in typical config
|
||||||
|
text = text.replace("REGISTRY_HOST_IP", registry_ip)
|
||||||
text = text.replace("\n annotations: null", "")
|
text = text.replace("\n annotations: null", "")
|
||||||
return text
|
return text
|
||||||
|
|||||||
@@ -643,14 +643,18 @@ class TestConfigCli(unittest.TestCase):
|
|||||||
|
|
||||||
def test_config_cli_set_dispatch(self):
|
def test_config_cli_set_dispatch(self):
|
||||||
"""Test that config set CLI dispatches correctly."""
|
"""Test that config set CLI dispatches correctly."""
|
||||||
|
mock_existing = MagicMock()
|
||||||
|
mock_existing.production_host = "old@example.com"
|
||||||
|
mock_existing.infra_directory = "/old/infra"
|
||||||
|
mock_existing.acme_email = ""
|
||||||
mock_save = MagicMock()
|
mock_save = MagicMock()
|
||||||
mock_config = MagicMock(
|
mock_config = MagicMock(
|
||||||
SunbeamConfig=MagicMock(return_value="mock_config"),
|
load_config=MagicMock(return_value=mock_existing),
|
||||||
save_config=mock_save
|
save_config=mock_save
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch.object(sys, "argv", ["sunbeam", "config", "set",
|
with patch.object(sys, "argv", ["sunbeam", "config", "set",
|
||||||
"--host", "cli@example.com",
|
"--host", "cli@example.com",
|
||||||
"--infra-dir", "/cli/infra"]):
|
"--infra-dir", "/cli/infra"]):
|
||||||
with patch.dict("sys.modules", {"sunbeam.config": mock_config}):
|
with patch.dict("sys.modules", {"sunbeam.config": mock_config}):
|
||||||
import importlib, sunbeam.cli as cli_mod
|
import importlib, sunbeam.cli as cli_mod
|
||||||
@@ -659,14 +663,12 @@ class TestConfigCli(unittest.TestCase):
|
|||||||
cli_mod.main()
|
cli_mod.main()
|
||||||
except SystemExit:
|
except SystemExit:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Verify SunbeamConfig was called with correct args
|
# Verify existing config was loaded and updated
|
||||||
mock_config.SunbeamConfig.assert_called_once_with(
|
self.assertEqual(mock_existing.production_host, "cli@example.com")
|
||||||
production_host="cli@example.com",
|
self.assertEqual(mock_existing.infra_directory, "/cli/infra")
|
||||||
infra_directory="/cli/infra"
|
# Verify save_config was called with the updated config
|
||||||
)
|
mock_save.assert_called_once_with(mock_existing)
|
||||||
# Verify save_config was called
|
|
||||||
mock_save.assert_called_once_with("mock_config")
|
|
||||||
|
|
||||||
def test_config_cli_get_dispatch(self):
|
def test_config_cli_get_dispatch(self):
|
||||||
"""Test that config get CLI dispatches correctly."""
|
"""Test that config get CLI dispatches correctly."""
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"""Binary bundler — downloads kubectl, kustomize, helm at pinned versions.
|
"""Binary bundler — downloads kubectl, kustomize, helm, buildctl at pinned versions.
|
||||||
|
|
||||||
Binaries are cached in ~/.local/share/sunbeam/bin/ and SHA256-verified.
|
Binaries are cached in ~/.local/share/sunbeam/bin/ and SHA256-verified.
|
||||||
|
Platform (OS + arch) is detected at runtime so the same package works on
|
||||||
|
darwin/arm64 (development Mac), darwin/amd64, linux/arm64, and linux/amd64.
|
||||||
"""
|
"""
|
||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
|
import platform
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import tarfile
|
import tarfile
|
||||||
@@ -13,26 +16,79 @@ from pathlib import Path
|
|||||||
|
|
||||||
CACHE_DIR = Path.home() / ".local/share/sunbeam/bin"
|
CACHE_DIR = Path.home() / ".local/share/sunbeam/bin"
|
||||||
|
|
||||||
TOOLS: dict[str, dict] = {
|
# Tool specs — URL and extract templates use {version}, {os}, {arch}.
|
||||||
|
# {os} : darwin | linux
|
||||||
|
# {arch} : arm64 | amd64
|
||||||
|
_TOOL_SPECS: dict[str, dict] = {
|
||||||
"kubectl": {
|
"kubectl": {
|
||||||
"version": "v1.32.2",
|
"version": "v1.32.2",
|
||||||
"url": "https://dl.k8s.io/release/v1.32.2/bin/darwin/arm64/kubectl",
|
"url": "https://dl.k8s.io/release/{version}/bin/{os}/{arch}/kubectl",
|
||||||
"sha256": "", # set to actual hash; empty = skip verify
|
# plain binary, no archive
|
||||||
},
|
},
|
||||||
"kustomize": {
|
"kustomize": {
|
||||||
"version": "v5.8.1",
|
"version": "v5.8.1",
|
||||||
"url": "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.8.1/kustomize_v5.8.1_darwin_arm64.tar.gz",
|
"url": (
|
||||||
"sha256": "",
|
"https://github.com/kubernetes-sigs/kustomize/releases/download/"
|
||||||
|
"kustomize%2F{version}/kustomize_{version}_{os}_{arch}.tar.gz"
|
||||||
|
),
|
||||||
"extract": "kustomize",
|
"extract": "kustomize",
|
||||||
},
|
},
|
||||||
"helm": {
|
"helm": {
|
||||||
"version": "v4.1.0",
|
"version": "v4.1.0",
|
||||||
"url": "https://get.helm.sh/helm-v4.1.0-darwin-arm64.tar.gz",
|
"url": "https://get.helm.sh/helm-{version}-{os}-{arch}.tar.gz",
|
||||||
"sha256": "82f7065bf4e08d4c8d7881b85c0a080581ef4968a4ae6df4e7b432f8f7a88d0c",
|
"extract": "{os}-{arch}/helm",
|
||||||
"extract": "darwin-arm64/helm",
|
"sha256": {
|
||||||
|
"darwin_arm64": "82f7065bf4e08d4c8d7881b85c0a080581ef4968a4ae6df4e7b432f8f7a88d0c",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"buildctl": {
|
||||||
|
"version": "v0.28.0",
|
||||||
|
# BuildKit releases: buildkit-v0.28.0.linux.amd64.tar.gz
|
||||||
|
"url": (
|
||||||
|
"https://github.com/moby/buildkit/releases/download/{version}/"
|
||||||
|
"buildkit-{version}.{os}-{arch}.tar.gz"
|
||||||
|
),
|
||||||
|
"extract": "bin/buildctl",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Expose as TOOLS for callers that do `if "helm" in TOOLS`.
|
||||||
|
TOOLS = _TOOL_SPECS
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_platform() -> tuple[str, str]:
|
||||||
|
"""Return (os_name, arch) for the current host."""
|
||||||
|
sys_os = platform.system().lower()
|
||||||
|
machine = platform.machine().lower()
|
||||||
|
os_name = {"darwin": "darwin", "linux": "linux"}.get(sys_os)
|
||||||
|
if not os_name:
|
||||||
|
raise RuntimeError(f"Unsupported OS: {sys_os}")
|
||||||
|
arch = "arm64" if machine in ("arm64", "aarch64") else "amd64"
|
||||||
|
return os_name, arch
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_spec(name: str) -> dict:
|
||||||
|
"""Return a tool spec with {os} / {arch} / {version} substituted.
|
||||||
|
|
||||||
|
Uses the module-level TOOLS dict so that tests can patch it.
|
||||||
|
"""
|
||||||
|
if name not in TOOLS:
|
||||||
|
raise ValueError(f"Unknown tool: {name}")
|
||||||
|
os_name, arch = _detect_platform()
|
||||||
|
raw = TOOLS[name]
|
||||||
|
version = raw.get("version", "")
|
||||||
|
fmt = {"version": version, "os": os_name, "arch": arch}
|
||||||
|
spec = dict(raw)
|
||||||
|
spec["version"] = version
|
||||||
|
spec["url"] = raw["url"].format(**fmt)
|
||||||
|
if "extract" in raw:
|
||||||
|
spec["extract"] = raw["extract"].format(**fmt)
|
||||||
|
# sha256 may be a per-platform dict {"darwin_arm64": "..."} or a plain string.
|
||||||
|
sha256_val = raw.get("sha256", {})
|
||||||
|
if isinstance(sha256_val, dict):
|
||||||
|
spec["sha256"] = sha256_val.get(f"{os_name}_{arch}", "")
|
||||||
|
return spec
|
||||||
|
|
||||||
|
|
||||||
def _sha256(path: Path) -> str:
|
def _sha256(path: Path) -> str:
|
||||||
h = hashlib.sha256()
|
h = hashlib.sha256()
|
||||||
@@ -45,12 +101,10 @@ def _sha256(path: Path) -> str:
|
|||||||
def ensure_tool(name: str) -> Path:
|
def ensure_tool(name: str) -> Path:
|
||||||
"""Return path to cached binary, downloading + verifying if needed.
|
"""Return path to cached binary, downloading + verifying if needed.
|
||||||
|
|
||||||
Re-downloads automatically when the pinned version in TOOLS changes.
|
Re-downloads automatically when the pinned version in _TOOL_SPECS changes.
|
||||||
A <name>.version sidecar file records the version of the cached binary.
|
A <name>.version sidecar file records the version of the cached binary.
|
||||||
"""
|
"""
|
||||||
if name not in TOOLS:
|
spec = _resolve_spec(name)
|
||||||
raise ValueError(f"Unknown tool: {name}")
|
|
||||||
spec = TOOLS[name]
|
|
||||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
dest = CACHE_DIR / name
|
dest = CACHE_DIR / name
|
||||||
version_file = CACHE_DIR / f"{name}.version"
|
version_file = CACHE_DIR / f"{name}.version"
|
||||||
@@ -58,7 +112,6 @@ def ensure_tool(name: str) -> Path:
|
|||||||
expected_sha = spec.get("sha256", "")
|
expected_sha = spec.get("sha256", "")
|
||||||
expected_version = spec.get("version", "")
|
expected_version = spec.get("version", "")
|
||||||
|
|
||||||
# Use cached binary if version matches (or no version pinned) and SHA passes
|
|
||||||
if dest.exists():
|
if dest.exists():
|
||||||
version_ok = (
|
version_ok = (
|
||||||
not expected_version
|
not expected_version
|
||||||
@@ -67,18 +120,17 @@ def ensure_tool(name: str) -> Path:
|
|||||||
sha_ok = not expected_sha or _sha256(dest) == expected_sha
|
sha_ok = not expected_sha or _sha256(dest) == expected_sha
|
||||||
if version_ok and sha_ok:
|
if version_ok and sha_ok:
|
||||||
return dest
|
return dest
|
||||||
|
|
||||||
# Version mismatch or SHA mismatch — re-download
|
# Version mismatch or SHA mismatch — re-download
|
||||||
if dest.exists():
|
if dest.exists():
|
||||||
dest.unlink()
|
dest.unlink()
|
||||||
if version_file.exists():
|
if version_file.exists():
|
||||||
version_file.unlink()
|
version_file.unlink()
|
||||||
|
|
||||||
# Download
|
|
||||||
url = spec["url"]
|
url = spec["url"]
|
||||||
with urllib.request.urlopen(url) as resp: # noqa: S310
|
with urllib.request.urlopen(url) as resp: # noqa: S310
|
||||||
data = resp.read()
|
data = resp.read()
|
||||||
|
|
||||||
# Extract from tar.gz if needed
|
|
||||||
extract_path = spec.get("extract")
|
extract_path = spec.get("extract")
|
||||||
if extract_path:
|
if extract_path:
|
||||||
with tarfile.open(fileobj=io.BytesIO(data)) as tf:
|
with tarfile.open(fileobj=io.BytesIO(data)) as tf:
|
||||||
@@ -88,10 +140,8 @@ def ensure_tool(name: str) -> Path:
|
|||||||
else:
|
else:
|
||||||
binary_data = data
|
binary_data = data
|
||||||
|
|
||||||
# Write to cache
|
|
||||||
dest.write_bytes(binary_data)
|
dest.write_bytes(binary_data)
|
||||||
|
|
||||||
# Verify SHA256 (after extraction)
|
|
||||||
if expected_sha:
|
if expected_sha:
|
||||||
actual = _sha256(dest)
|
actual = _sha256(dest)
|
||||||
if actual != expected_sha:
|
if actual != expected_sha:
|
||||||
@@ -100,9 +150,7 @@ def ensure_tool(name: str) -> Path:
|
|||||||
f"SHA256 mismatch for {name}: expected {expected_sha}, got {actual}"
|
f"SHA256 mismatch for {name}: expected {expected_sha}, got {actual}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Make executable
|
|
||||||
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||||
# Record version so future calls skip re-download when version unchanged
|
|
||||||
version_file.write_text(expected_version)
|
version_file.write_text(expected_version)
|
||||||
return dest
|
return dest
|
||||||
|
|
||||||
@@ -116,9 +164,8 @@ def run_tool(name: str, *args, **kwargs) -> subprocess.CompletedProcess:
|
|||||||
env = kwargs.pop("env", None)
|
env = kwargs.pop("env", None)
|
||||||
if env is None:
|
if env is None:
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
# kustomize needs helm on PATH for helm chart rendering
|
|
||||||
if name == "kustomize":
|
if name == "kustomize":
|
||||||
if "helm" in TOOLS:
|
if "helm" in TOOLS:
|
||||||
ensure_tool("helm") # ensure bundled helm is present before kustomize runs
|
ensure_tool("helm")
|
||||||
env["PATH"] = str(CACHE_DIR) + os.pathsep + env.get("PATH", "")
|
env["PATH"] = str(CACHE_DIR) + os.pathsep + env.get("PATH", "")
|
||||||
return subprocess.run([str(bin_path), *args], env=env, **kwargs)
|
return subprocess.run([str(bin_path), *args], env=env, **kwargs)
|
||||||
|
|||||||
Reference in New Issue
Block a user