feat(userbot): add Kubernetes control panel
Manage Telegram instances through Kubernetes with legacy adoption for forust and anna. Build and deploy the panel image alongside the runtime.
This commit is contained in:
1 parent
30e6f05584
commit
71cddd6a91
41 files changed
+8268
-21
No files matched your search
@@ -0,0 +1,84 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from app.auth_service import TelegramAuthService
|
||||
from app.models import PhoneStart, StringSessionStart
|
||||
|
||||
|
||||
class FakeClient:
|
||||
instances = []
|
||||
requires_password = False
|
||||
|
||||
def __init__(self, *_args, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self.is_connected = False
|
||||
self.disconnected = False
|
||||
self.__class__.instances.append(self)
|
||||
|
||||
async def connect(self):
|
||||
self.is_connected = True
|
||||
|
||||
async def disconnect(self):
|
||||
self.is_connected = False
|
||||
self.disconnected = True
|
||||
|
||||
async def send_code(self, _phone):
|
||||
return SimpleNamespace(phone_code_hash="hash")
|
||||
|
||||
async def sign_in(self, *_args):
|
||||
if self.requires_password:
|
||||
from pyrogram.errors import SessionPasswordNeeded
|
||||
|
||||
raise SessionPasswordNeeded()
|
||||
|
||||
async def check_password(self, _password):
|
||||
return None
|
||||
|
||||
async def get_me(self):
|
||||
return SimpleNamespace(id=1)
|
||||
|
||||
async def export_session_string(self):
|
||||
return "exported-session"
|
||||
|
||||
|
||||
def phone_payload() -> PhoneStart:
|
||||
return PhoneStart(
|
||||
instance_id="test",
|
||||
display_name="Test",
|
||||
api_id=123,
|
||||
api_hash="0123456789abcdef",
|
||||
phone="+421900000000",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_phone_code_success_closes_and_forgets_client(monkeypatch) -> None:
|
||||
monkeypatch.setattr("app.auth_service.Client", FakeClient)
|
||||
auth = TelegramAuthService()
|
||||
|
||||
flow_id = await auth.start_phone(phone_payload())
|
||||
result = await auth.submit_code(flow_id, "12345")
|
||||
|
||||
assert result.session_string == "exported-session"
|
||||
assert flow_id not in auth.flows
|
||||
assert FakeClient.instances[-1].disconnected is True
|
||||
assert FakeClient.instances[-1].kwargs["in_memory"] is True
|
||||
assert FakeClient.instances[-1].kwargs["no_updates"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_string_session_is_validated_and_closed(monkeypatch) -> None:
|
||||
monkeypatch.setattr("app.auth_service.Client", FakeClient)
|
||||
auth = TelegramAuthService()
|
||||
payload = StringSessionStart(
|
||||
instance_id="test",
|
||||
display_name="Test",
|
||||
api_id=123,
|
||||
api_hash="0123456789abcdef",
|
||||
session_string="x" * 64,
|
||||
)
|
||||
|
||||
result = await auth.validate_string_session(payload)
|
||||
|
||||
assert result.session_string == "exported-session"
|
||||
assert FakeClient.instances[-1].disconnected is True
|
||||
@@ -0,0 +1,179 @@
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from app.config import Settings
|
||||
from app.errors import PanelError
|
||||
from app.kubernetes_service import KubernetesService
|
||||
from app.models import AccountBase
|
||||
from kubernetes.client.exceptions import ApiException
|
||||
|
||||
|
||||
def service() -> KubernetesService:
|
||||
core = Mock()
|
||||
apps = Mock()
|
||||
custom = Mock()
|
||||
custom.get_namespaced_custom_object.side_effect = ApiException(status=404)
|
||||
return KubernetesService(Settings(), core=core, apps=apps, custom=custom)
|
||||
|
||||
|
||||
def account() -> AccountBase:
|
||||
return AccountBase(
|
||||
instance_id="test-account",
|
||||
display_name="Test Account",
|
||||
api_id=12345,
|
||||
api_hash="0123456789abcdef0123456789abcdef",
|
||||
)
|
||||
|
||||
|
||||
def test_renders_managed_resources_without_leaking_credentials() -> None:
|
||||
kube = service()
|
||||
names = kube._resource_names("test-account")
|
||||
secret = kube._secret(account(), "SESSION", names)
|
||||
pvc = kube._pvc(account(), names)
|
||||
deployment = kube._deployment(account(), names)
|
||||
|
||||
assert secret.string_data == {
|
||||
"API_ID": "12345",
|
||||
"API_HASH": "0123456789abcdef0123456789abcdef",
|
||||
"STRINGSESSION": "SESSION",
|
||||
}
|
||||
assert pvc.spec.storage_class_name == "local-path-retain"
|
||||
assert pvc.spec.resources.requests["storage"] == "1Gi"
|
||||
assert deployment.spec.strategy.type == "Recreate"
|
||||
assert deployment.spec.replicas == 1
|
||||
assert deployment.spec.template.spec.automount_service_account_token is False
|
||||
assert deployment.spec.template.spec.service_account_name == "userbot-runtime"
|
||||
assert deployment.spec.template.spec.volumes[1].host_path.path.endswith("/Downloads")
|
||||
assert deployment.spec.template.spec.containers[0].resources.requests == {
|
||||
"cpu": "80m",
|
||||
"memory": "512Mi",
|
||||
}
|
||||
|
||||
|
||||
def test_collision_is_reported_before_auth() -> None:
|
||||
kube = service()
|
||||
kube.apps.read_namespaced_deployment.return_value = object()
|
||||
|
||||
with pytest.raises(PanelError) as error:
|
||||
kube.assert_available("test-account")
|
||||
|
||||
assert error.value.status_code == 409
|
||||
assert "Deployment" in error.value.detail
|
||||
|
||||
|
||||
def test_partial_provision_rolls_back_only_created_resources() -> None:
|
||||
kube = service()
|
||||
kube.ensure_prerequisites = Mock()
|
||||
kube.assert_available = Mock()
|
||||
kube.core.create_namespaced_persistent_volume_claim.side_effect = ApiException(status=500)
|
||||
|
||||
with pytest.raises(PanelError):
|
||||
kube.provision(account(), "SESSION")
|
||||
|
||||
kube.core.delete_namespaced_secret.assert_called_once_with(
|
||||
"userbot-test-account-credentials",
|
||||
"userbot",
|
||||
)
|
||||
kube.core.delete_namespaced_persistent_volume_claim.assert_not_called()
|
||||
kube.apps.delete_namespaced_deployment.assert_not_called()
|
||||
|
||||
|
||||
def test_delete_retains_pvc_unless_explicitly_requested() -> None:
|
||||
kube = service()
|
||||
deployment = SimpleNamespace(
|
||||
metadata=SimpleNamespace(
|
||||
name="userbot-test-account",
|
||||
annotations={
|
||||
"userbot.forust.xyz/credentials-secret": "credentials",
|
||||
"userbot.forust.xyz/pvc": "data",
|
||||
},
|
||||
)
|
||||
)
|
||||
kube._find_deployment = Mock(return_value=("userbot", deployment))
|
||||
|
||||
kube.delete("test-account", delete_data=False)
|
||||
|
||||
kube.apps.delete_namespaced_deployment.assert_called_once()
|
||||
kube.core.delete_namespaced_secret.assert_called_once_with("credentials", "userbot")
|
||||
kube.core.delete_namespaced_persistent_volume_claim.assert_not_called()
|
||||
|
||||
kube.delete("test-account", delete_data=True)
|
||||
kube.core.delete_namespaced_persistent_volume_claim.assert_called_once_with(
|
||||
"data",
|
||||
"userbot",
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_delete_is_blocked() -> None:
|
||||
kube = service()
|
||||
deployment = SimpleNamespace(
|
||||
metadata=SimpleNamespace(
|
||||
name="forust-userbot-deployment",
|
||||
annotations={"userbot.forust.xyz/legacy": "true"},
|
||||
)
|
||||
)
|
||||
kube._find_deployment = Mock(return_value=("default", deployment))
|
||||
|
||||
with pytest.raises(PanelError) as error:
|
||||
kube.delete("forust", delete_data=False)
|
||||
|
||||
assert error.value.status_code == 409
|
||||
|
||||
|
||||
def pod(*, ready: bool, phase: str = "Running", reason: str | None = None):
|
||||
waiting = SimpleNamespace(reason=reason) if reason else None
|
||||
state = SimpleNamespace(waiting=waiting, terminated=None)
|
||||
status = SimpleNamespace(
|
||||
container_statuses=[
|
||||
SimpleNamespace(ready=ready, restart_count=2, state=state),
|
||||
],
|
||||
phase=phase,
|
||||
start_time=datetime.now(UTC),
|
||||
)
|
||||
return SimpleNamespace(
|
||||
metadata=SimpleNamespace(name="pod-1", creation_timestamp=datetime.now(UTC)),
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
def deployment(replicas: int = 1):
|
||||
resources = SimpleNamespace(limits={"cpu": "300m", "memory": "1536Mi"})
|
||||
container = SimpleNamespace(image="userbot:latest", resources=resources)
|
||||
template_spec = SimpleNamespace(containers=[container], volumes=[])
|
||||
return SimpleNamespace(
|
||||
metadata=SimpleNamespace(
|
||||
name="userbot-test-account",
|
||||
labels={"app.kubernetes.io/instance": "test-account"},
|
||||
annotations={},
|
||||
creation_timestamp=datetime.now(UTC),
|
||||
),
|
||||
spec=SimpleNamespace(
|
||||
replicas=replicas,
|
||||
selector=SimpleNamespace(match_labels={"app": "test"}),
|
||||
template=SimpleNamespace(spec=template_spec),
|
||||
),
|
||||
status=SimpleNamespace(available_replicas=1 if replicas else 0),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("replicas", "pod_value", "expected"),
|
||||
[
|
||||
(0, None, "stopped"),
|
||||
(1, pod(ready=True), "running"),
|
||||
(1, pod(ready=False, phase="Pending"), "pending"),
|
||||
(1, pod(ready=False, reason="CrashLoopBackOff"), "error"),
|
||||
(1, pod(ready=False, phase="Failed"), "error"),
|
||||
],
|
||||
)
|
||||
def test_status_classification(replicas, pod_value, expected) -> None:
|
||||
kube = service()
|
||||
kube._pods_for_deployment = Mock(return_value=[pod_value] if pod_value else [])
|
||||
kube._pvc_storage = Mock(return_value=None)
|
||||
kube._pod_metrics = Mock(return_value=(None, None))
|
||||
|
||||
result = kube._summarize("userbot", deployment(replicas))
|
||||
|
||||
assert result.status == expected
|
||||
@@ -0,0 +1,44 @@
|
||||
import pytest
|
||||
from app.main import validation_error_handler
|
||||
from app.models import AccountBase, DeleteRequest
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"instance_id",
|
||||
["Upper", "has_space", "-leading", "trailing-", "x" * 41],
|
||||
)
|
||||
def test_invalid_instance_ids(instance_id: str) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
AccountBase(
|
||||
instance_id=instance_id,
|
||||
display_name="Test",
|
||||
api_id=1,
|
||||
api_hash="0123456789abcdef",
|
||||
)
|
||||
|
||||
|
||||
def test_delete_data_defaults_to_false() -> None:
|
||||
request = DeleteRequest(confirmation="test")
|
||||
assert request.delete_data is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validation_response_does_not_echo_secret_input() -> None:
|
||||
sensitive_value = "-".join(("very", "private", "string", "session"))
|
||||
error = RequestValidationError(
|
||||
[
|
||||
{
|
||||
"type": "string_too_short",
|
||||
"loc": ("body", "session_string"),
|
||||
"msg": "String should have at least 32 characters",
|
||||
"input": sensitive_value,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
response = await validation_error_handler(None, error)
|
||||
|
||||
assert sensitive_value.encode() not in response.body
|
||||
assert b'"input"' not in response.body
|
||||
Reference in new issue
Block a user