@@ -0,0 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy_services.base.service import BaseMicroservice
|
||||
|
||||
__all__ = ["BaseMicroservice"]
|
||||
@@ -0,0 +1,61 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Header, Request
|
||||
|
||||
from devplacepy_services.base.errors import http_error
|
||||
from devplacepy_services.base.schemas import InternalUser
|
||||
|
||||
_KEY_CACHE = ""
|
||||
|
||||
|
||||
def internal_gateway_key() -> str:
|
||||
global _KEY_CACHE
|
||||
if _KEY_CACHE:
|
||||
return _KEY_CACHE
|
||||
env_key = os.environ.get("DEVPLACE_GATEWAY_INTERNAL_KEY", "").strip()
|
||||
if env_key:
|
||||
_KEY_CACHE = env_key
|
||||
return env_key
|
||||
try:
|
||||
from devplacepy.db_client import internal_gateway_key as load_key
|
||||
|
||||
loaded = load_key().strip()
|
||||
if loaded:
|
||||
_KEY_CACHE = loaded
|
||||
return loaded
|
||||
except ImportError:
|
||||
return ""
|
||||
|
||||
|
||||
def set_internal_gateway_key(value: str) -> None:
|
||||
global _KEY_CACHE
|
||||
_KEY_CACHE = value.strip()
|
||||
|
||||
|
||||
def validate_internal_key(presented: str | None) -> bool:
|
||||
expected = internal_gateway_key()
|
||||
if not expected:
|
||||
return True
|
||||
if not presented:
|
||||
return False
|
||||
return presented.strip() == expected
|
||||
|
||||
|
||||
def require_internal_key(request: Request) -> None:
|
||||
if validate_internal_key(request.headers.get("X-Internal-Key")):
|
||||
return
|
||||
raise http_error(401, "Unauthorized", "unauthorized")
|
||||
|
||||
|
||||
async def internal_user(
|
||||
request: Request,
|
||||
x_authenticated_user: Annotated[str | None, Header()] = None,
|
||||
) -> InternalUser:
|
||||
require_internal_key(request)
|
||||
uid = x_authenticated_user.strip() if x_authenticated_user else None
|
||||
return InternalUser(uid=uid or None)
|
||||
@@ -0,0 +1,4 @@
|
||||
import os
|
||||
|
||||
os.environ["DEVPLACE_REMOTE_DB"] = "1"
|
||||
import devplacepy.db_client
|
||||
@@ -0,0 +1,67 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
|
||||
|
||||
class TTLCache:
|
||||
def __init__(self, ttl: int, max_size: int = 0):
|
||||
self.ttl = ttl
|
||||
self.max_size = max_size
|
||||
self._store: OrderedDict[str, tuple[object, float]] = OrderedDict()
|
||||
|
||||
def get(self, key: str):
|
||||
entry = self._store.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
value, expiry = entry
|
||||
if time.time() >= expiry:
|
||||
self._store.pop(key, None)
|
||||
return None
|
||||
self._store.move_to_end(key)
|
||||
return value
|
||||
|
||||
def set(self, key: str, value) -> None:
|
||||
self._store[key] = (value, time.time() + self.ttl)
|
||||
self._store.move_to_end(key)
|
||||
if self.max_size and len(self._store) > self.max_size:
|
||||
self._store.popitem(last=False)
|
||||
|
||||
def pop(self, key: str) -> None:
|
||||
self._store.pop(key, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._store.clear()
|
||||
|
||||
|
||||
class CacheStateGate:
|
||||
def __init__(self, *, ttl: int = 60, max_staleness: float = 5.0):
|
||||
self.ttl = ttl
|
||||
self.max_staleness = max_staleness
|
||||
self._cache = TTLCache(ttl=ttl)
|
||||
self._versions: dict[str, int] = {}
|
||||
self._bumped_at: dict[str, float] = {}
|
||||
|
||||
def get_version(self, name: str) -> int:
|
||||
return int(self._versions.get(name, 0))
|
||||
|
||||
def bump(self, name: str) -> int:
|
||||
next_version = self.get_version(name) + 1
|
||||
self._versions[name] = next_version
|
||||
self._bumped_at[name] = time.time()
|
||||
self._cache.clear()
|
||||
return next_version
|
||||
|
||||
def get(self, key: str, version_name: str, loader):
|
||||
bumped_at = self._bumped_at.get(version_name, 0.0)
|
||||
if bumped_at and (time.time() - bumped_at) < self.max_staleness:
|
||||
self._cache.pop(key)
|
||||
version_key = f"{version_name}:{self.get_version(version_name)}"
|
||||
cached = self._cache.get(version_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
value = loader()
|
||||
self._cache.set(version_key, value)
|
||||
return value
|
||||
@@ -0,0 +1,153 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class InvokeIn(BaseModel):
|
||||
fn: str
|
||||
args: list[Any] = Field(default_factory=list)
|
||||
kwargs: dict[str, Any] = Field(default_factory=dict)
|
||||
write: bool = False
|
||||
|
||||
|
||||
class CacheBumpIn(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class UsersByUidsIn(BaseModel):
|
||||
uids: list[str]
|
||||
|
||||
|
||||
class CommentCountsIn(BaseModel):
|
||||
post_uids: list[str]
|
||||
|
||||
|
||||
class VoteCountsIn(BaseModel):
|
||||
target_uids: list[str]
|
||||
|
||||
|
||||
class ReactionsIn(BaseModel):
|
||||
target_type: str
|
||||
target_uids: list[str]
|
||||
user: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class BookmarksIn(BaseModel):
|
||||
user_uid: str
|
||||
target_type: str
|
||||
target_uids: list[str]
|
||||
|
||||
|
||||
class PollsIn(BaseModel):
|
||||
post_uids: list[str]
|
||||
user: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class RecentCommentsIn(BaseModel):
|
||||
post_uids: list[str]
|
||||
limit: int = 3
|
||||
user: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class CommentsIn(BaseModel):
|
||||
target_type: str
|
||||
target_uid: str
|
||||
user: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class FollowBundleIn(BaseModel):
|
||||
user_uid: str
|
||||
target_uids: list[str] | None = None
|
||||
|
||||
|
||||
class RelationsIn(BaseModel):
|
||||
viewer_uid: str | None = None
|
||||
|
||||
|
||||
class OnlineUsersIn(BaseModel):
|
||||
cutoff_iso: str
|
||||
limit: int = 30
|
||||
|
||||
|
||||
class NotificationPrefsIn(BaseModel):
|
||||
user_uid: str
|
||||
|
||||
|
||||
class LeaderboardBundleIn(BaseModel):
|
||||
limit: int = 50
|
||||
offset: int = 0
|
||||
viewer_uid: str | None = None
|
||||
|
||||
|
||||
class SiteSidebarIn(BaseModel):
|
||||
authors_limit: int = 5
|
||||
|
||||
|
||||
class AwardsBundleIn(BaseModel):
|
||||
receiver_uid: str
|
||||
page: int = 1
|
||||
per_page: int = 12
|
||||
profile_user: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class AttachmentsIn(BaseModel):
|
||||
resource_type: str
|
||||
resource_uids: list[str]
|
||||
|
||||
|
||||
class UserMediaIn(BaseModel):
|
||||
user_uid: str
|
||||
page: int = 1
|
||||
per_page: int = 24
|
||||
|
||||
|
||||
class SeoMetaIn(BaseModel):
|
||||
target_type: str
|
||||
uids: list[str]
|
||||
|
||||
|
||||
class FeedPageIn(BaseModel):
|
||||
user: dict[str, Any] | None = None
|
||||
tab: str = "all"
|
||||
topic: str | None = None
|
||||
search: str = ""
|
||||
before: str | None = None
|
||||
|
||||
|
||||
class PostDetailIn(BaseModel):
|
||||
post_uid: str
|
||||
user: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ProfileBundleIn(BaseModel):
|
||||
profile_uid: str
|
||||
viewer: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class MessagesPageIn(BaseModel):
|
||||
user_uid: str
|
||||
|
||||
|
||||
class NotificationsPageIn(BaseModel):
|
||||
user_uid: str
|
||||
|
||||
|
||||
class LeaderboardPageIn(BaseModel):
|
||||
viewer_uid: str | None = None
|
||||
|
||||
|
||||
class ProjectDetailIn(BaseModel):
|
||||
project_uid: str
|
||||
|
||||
|
||||
class GistDetailIn(BaseModel):
|
||||
gist_uid: str
|
||||
user: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class GameStateIn(BaseModel):
|
||||
user_uid: str | None = None
|
||||
@@ -0,0 +1,70 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from devplacepy_services.base.manifest import (
|
||||
FORBIDDEN_GAPS,
|
||||
PORT_HINTS,
|
||||
SERVICE_URL_ENV,
|
||||
SERVICES,
|
||||
build_profiles,
|
||||
)
|
||||
|
||||
PROFILES = build_profiles()
|
||||
|
||||
_BROKER_URL_ENV = {
|
||||
"main": "DEVPLACE_DB_SERVICE_URL",
|
||||
"devii_tasks": "DEVPLACE_DEVII_TASKS_STORE_URL",
|
||||
"devii_lessons": "DEVPLACE_DEVII_LESSONS_STORE_URL",
|
||||
}
|
||||
|
||||
|
||||
def port_profile() -> str:
|
||||
return os.environ.get("DEVPLACE_PORT_PROFILE", "micro-dev")
|
||||
|
||||
|
||||
def data_dir() -> Path:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
return Path(os.environ.get("DEVPLACE_DATA_DIR", str(root / "data")))
|
||||
|
||||
|
||||
def service_url(name: str) -> str:
|
||||
env_key = SERVICE_URL_ENV.get(name)
|
||||
if env_key:
|
||||
override = os.environ.get(env_key, "").strip().rstrip("/")
|
||||
if override:
|
||||
return override
|
||||
profile = port_profile()
|
||||
host = os.environ.get("DEVPLACE_SERVICE_HOST", "127.0.0.1")
|
||||
port = PROFILES[profile][name]
|
||||
return f"http://{host}:{port}"
|
||||
|
||||
|
||||
def broker_url(name: str) -> str:
|
||||
env_key = _BROKER_URL_ENV.get(name)
|
||||
if env_key:
|
||||
override = os.environ.get(env_key, "").strip().rstrip("/")
|
||||
if override:
|
||||
return override
|
||||
if name == "main":
|
||||
return service_url("database")
|
||||
if name == "devii_tasks":
|
||||
return f"{service_url('devii')}/internal/store/devii_tasks"
|
||||
if name == "devii_lessons":
|
||||
return f"{service_url('devii')}/internal/store/devii_lessons"
|
||||
raise KeyError(name)
|
||||
|
||||
|
||||
def sqlite_read_pool_size() -> int:
|
||||
raw = os.environ.get("DEVPLACE_SQLITE_READ_POOL", "8").strip()
|
||||
try:
|
||||
return max(1, int(raw))
|
||||
except ValueError:
|
||||
return 8
|
||||
|
||||
|
||||
def service_spec(name: str):
|
||||
return SERVICES[name]
|
||||
@@ -0,0 +1,236 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from devplacepy_services.base.compound import (
|
||||
AttachmentsIn,
|
||||
AwardsBundleIn,
|
||||
BookmarksIn,
|
||||
CacheBumpIn,
|
||||
CommentCountsIn,
|
||||
CommentsIn,
|
||||
FeedPageIn,
|
||||
FollowBundleIn,
|
||||
LeaderboardBundleIn,
|
||||
NotificationPrefsIn,
|
||||
OnlineUsersIn,
|
||||
PollsIn,
|
||||
ReactionsIn,
|
||||
RecentCommentsIn,
|
||||
RelationsIn,
|
||||
SeoMetaIn,
|
||||
SiteSidebarIn,
|
||||
UserMediaIn,
|
||||
UsersByUidsIn,
|
||||
VoteCountsIn,
|
||||
)
|
||||
from devplacepy_services.base.errors import http_error
|
||||
from devplacepy_services.base.http import internal_request
|
||||
|
||||
|
||||
class BrokerClient:
|
||||
def __init__(self, name: str, url: str | None = None) -> None:
|
||||
self.name = name
|
||||
if url is not None:
|
||||
self.url = url.rstrip("/")
|
||||
else:
|
||||
from devplacepy_services.base.config import broker_url
|
||||
|
||||
self.url = broker_url(name).rstrip("/")
|
||||
|
||||
async def post(self, path: str, body: dict[str, Any] | None = None) -> Any:
|
||||
target = f"{self.url}/{path.lstrip('/')}"
|
||||
response = await internal_request("POST", target, json=body or {})
|
||||
if response.status_code >= 400:
|
||||
payload = response.json() if response.content else {}
|
||||
message = payload.get("error", "Broker request failed")
|
||||
code = payload.get("code", "broker_error")
|
||||
raise http_error(response.status_code, message, code)
|
||||
if not response.content:
|
||||
return None
|
||||
return response.json()
|
||||
|
||||
async def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
|
||||
target = f"{self.url}/{path.lstrip('/')}"
|
||||
response = await internal_request("GET", target, params=params)
|
||||
if response.status_code >= 400:
|
||||
payload = response.json() if response.content else {}
|
||||
message = payload.get("error", "Broker request failed")
|
||||
code = payload.get("code", "broker_error")
|
||||
raise http_error(response.status_code, message, code)
|
||||
if not response.content:
|
||||
return None
|
||||
return response.json()
|
||||
|
||||
|
||||
main = BrokerClient("main")
|
||||
devii_tasks = BrokerClient("devii_tasks")
|
||||
devii_lessons = BrokerClient("devii_lessons")
|
||||
|
||||
|
||||
async def get_setting(key: str, default: str = "") -> str:
|
||||
payload = await main.get(f"settings/{key}", params={"default": default})
|
||||
return payload.get("value", default) if isinstance(payload, dict) else default
|
||||
|
||||
|
||||
async def get_int_setting(key: str, default: int) -> int:
|
||||
raw = await get_setting(key, str(default))
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
async def bump_cache_version(name: str) -> None:
|
||||
await main.post("/cache/bump", CacheBumpIn(name=name).model_dump())
|
||||
|
||||
|
||||
async def get_users_by_uids(uids: list[str]) -> dict:
|
||||
return await main.post("/compound/users-by-uids", UsersByUidsIn(uids=uids).model_dump())
|
||||
|
||||
|
||||
async def get_comment_counts_by_post_uids(post_uids: list[str]) -> dict:
|
||||
return await main.post(
|
||||
"/compound/comment-counts", CommentCountsIn(post_uids=post_uids).model_dump()
|
||||
)
|
||||
|
||||
|
||||
async def get_vote_counts(target_uids: list[str]) -> tuple[dict, dict]:
|
||||
payload = await main.post(
|
||||
"/compound/vote-counts", VoteCountsIn(target_uids=target_uids).model_dump()
|
||||
)
|
||||
return payload.get("ups", {}), payload.get("downs", {})
|
||||
|
||||
|
||||
async def get_reactions_by_targets(
|
||||
target_type: str, target_uids: list[str], user: dict | None = None
|
||||
) -> dict:
|
||||
return await main.post(
|
||||
"/compound/reactions",
|
||||
ReactionsIn(target_type=target_type, target_uids=target_uids, user=user).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
async def get_user_bookmarks(
|
||||
user_uid: str, target_type: str, target_uids: list[str]
|
||||
) -> set[str]:
|
||||
payload = await main.post(
|
||||
"/compound/bookmarks",
|
||||
BookmarksIn(
|
||||
user_uid=user_uid, target_type=target_type, target_uids=target_uids
|
||||
).model_dump(),
|
||||
)
|
||||
return set(payload or [])
|
||||
|
||||
|
||||
async def get_polls_by_post_uids(post_uids: list[str], user: dict | None = None) -> dict:
|
||||
return await main.post(
|
||||
"/compound/polls", PollsIn(post_uids=post_uids, user=user).model_dump()
|
||||
)
|
||||
|
||||
|
||||
async def get_recent_comments_by_post_uids(
|
||||
post_uids: list[str], limit: int = 3, user: dict | None = None
|
||||
) -> dict:
|
||||
return await main.post(
|
||||
"/compound/recent-comments",
|
||||
RecentCommentsIn(post_uids=post_uids, limit=limit, user=user).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
async def load_comments(
|
||||
target_type: str, target_uid: str, user: dict | None = None
|
||||
) -> list:
|
||||
return await main.post(
|
||||
"/compound/comments",
|
||||
CommentsIn(target_type=target_type, target_uid=target_uid, user=user).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
async def get_follow_bundle(
|
||||
user_uid: str, target_uids: list[str] | None = None
|
||||
) -> dict:
|
||||
return await main.post(
|
||||
"/compound/follow-bundle",
|
||||
FollowBundleIn(user_uid=user_uid, target_uids=target_uids).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
async def get_user_relations_bundle(viewer_uid: str | None = None) -> dict:
|
||||
return await main.post(
|
||||
"/compound/relations", RelationsIn(viewer_uid=viewer_uid).model_dump()
|
||||
)
|
||||
|
||||
|
||||
async def get_online_users(cutoff_iso: str, limit: int = 30) -> list:
|
||||
return await main.post(
|
||||
"/compound/online-users",
|
||||
OnlineUsersIn(cutoff_iso=cutoff_iso, limit=limit).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
async def get_notification_prefs(user_uid: str) -> list:
|
||||
return await main.post(
|
||||
"/compound/notification-prefs",
|
||||
NotificationPrefsIn(user_uid=user_uid).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
async def get_leaderboard_bundle(
|
||||
limit: int = 50, offset: int = 0, viewer_uid: str | None = None
|
||||
) -> dict:
|
||||
return await main.post(
|
||||
"/compound/leaderboard",
|
||||
LeaderboardBundleIn(limit=limit, offset=offset, viewer_uid=viewer_uid).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
async def get_site_sidebar(authors_limit: int = 5) -> dict:
|
||||
return await main.post(
|
||||
"/compound/site-sidebar", SiteSidebarIn(authors_limit=authors_limit).model_dump()
|
||||
)
|
||||
|
||||
|
||||
async def get_awards_bundle(
|
||||
receiver_uid: str,
|
||||
page: int = 1,
|
||||
per_page: int = 12,
|
||||
profile_user: dict | None = None,
|
||||
) -> dict:
|
||||
return await main.post(
|
||||
"/compound/awards",
|
||||
AwardsBundleIn(
|
||||
receiver_uid=receiver_uid,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
profile_user=profile_user,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
async def get_attachments_batch(resource_type: str, resource_uids: list[str]) -> dict:
|
||||
return await main.post(
|
||||
"/compound/attachments",
|
||||
AttachmentsIn(resource_type=resource_type, resource_uids=resource_uids).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
async def get_user_media(
|
||||
user_uid: str, page: int = 1, per_page: int = 24
|
||||
) -> dict:
|
||||
return await main.post(
|
||||
"/compound/user-media",
|
||||
UserMediaIn(user_uid=user_uid, page=page, per_page=per_page).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
async def get_seo_metadata_batch(target_type: str, uids: list[str]) -> dict:
|
||||
return await main.post(
|
||||
"/compound/seo-meta", SeoMetaIn(target_type=target_type, uids=uids).model_dump()
|
||||
)
|
||||
|
||||
|
||||
async def build_feed_page(**kwargs) -> dict:
|
||||
return await main.post("/compound/feed-page", FeedPageIn(**kwargs).model_dump())
|
||||
@@ -0,0 +1,126 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
WRITE_PREFIXES = (
|
||||
"set_",
|
||||
"add_",
|
||||
"delete_",
|
||||
"create_",
|
||||
"update_",
|
||||
"upsert_",
|
||||
"record_",
|
||||
"migrate_",
|
||||
"backfill_",
|
||||
"mark_",
|
||||
"invalidate_",
|
||||
"ensure_",
|
||||
"restore",
|
||||
"purge",
|
||||
"revoke_",
|
||||
"recompute_",
|
||||
"soft_delete",
|
||||
"bump_",
|
||||
"init_",
|
||||
"clear_settings",
|
||||
)
|
||||
|
||||
WRITE_EXACT = frozenset(
|
||||
{
|
||||
"soft_delete_in",
|
||||
"restore_event",
|
||||
"purge_event",
|
||||
"delete_engagement",
|
||||
"soft_delete_engagement",
|
||||
"delete_fork_relations",
|
||||
"soft_delete_fork_relations",
|
||||
"delete_attachments",
|
||||
"delete_attachment_record",
|
||||
"_delete_attachment_file",
|
||||
"_index",
|
||||
"_drop_index",
|
||||
"_uid_index",
|
||||
"_ensure_cache_state",
|
||||
"_refresh_query_planner_stats",
|
||||
"_backfill_gamification",
|
||||
}
|
||||
)
|
||||
|
||||
REMOTE_TABLE_MARKER = "__remote_table__"
|
||||
|
||||
|
||||
def is_write(name: str) -> bool:
|
||||
if name in WRITE_EXACT:
|
||||
return True
|
||||
return any(name.startswith(prefix) for prefix in WRITE_PREFIXES)
|
||||
|
||||
|
||||
SQL_WRITE_KEYWORDS = frozenset(
|
||||
{"INSERT", "UPDATE", "DELETE", "REPLACE", "CREATE", "ALTER", "DROP"}
|
||||
)
|
||||
|
||||
|
||||
def is_write_sql(sql: str) -> bool:
|
||||
first_word = sql.strip().split(None, 1)[0].upper() if sql.strip() else ""
|
||||
return first_word in SQL_WRITE_KEYWORDS
|
||||
|
||||
|
||||
def encode_value(value: Any) -> Any:
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
return value
|
||||
if type(value).__name__ == "RemoteTable":
|
||||
return {REMOTE_TABLE_MARKER: value._name}
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Path):
|
||||
return str(value)
|
||||
if isinstance(value, frozenset):
|
||||
return [encode_value(item) for item in value]
|
||||
if isinstance(value, set):
|
||||
return [encode_value(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return [encode_value(item) for item in value]
|
||||
if isinstance(value, list):
|
||||
return [encode_value(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(key): encode_value(item) for key, item in value.items()}
|
||||
if hasattr(value, "items") and callable(value.items):
|
||||
try:
|
||||
return {str(key): encode_value(item) for key, item in value.items()}
|
||||
except TypeError:
|
||||
pass
|
||||
return str(value)
|
||||
|
||||
|
||||
def encode_args(args: tuple | list, kwargs: dict) -> tuple[list, dict]:
|
||||
return [encode_value(item) for item in args], {
|
||||
str(key): encode_value(value) for key, value in kwargs.items()
|
||||
}
|
||||
|
||||
|
||||
def decode_value(value: Any) -> Any:
|
||||
if isinstance(value, list):
|
||||
return [decode_value(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: decode_value(item) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def decode_arg(value: Any) -> Any:
|
||||
if isinstance(value, list):
|
||||
return [decode_arg(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
if set(value) == {REMOTE_TABLE_MARKER}:
|
||||
from devplacepy.db_client import get_table
|
||||
|
||||
return get_table(value[REMOTE_TABLE_MARKER])
|
||||
return {key: decode_arg(item) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def encode_result(value: Any) -> Any:
|
||||
return encode_value(value)
|
||||
@@ -0,0 +1,63 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from devplacepy_services.base.schemas import ErrorOut
|
||||
|
||||
|
||||
def error_response(status_code: int, message: str, code: str) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
ErrorOut(error=message, code=code).model_dump(),
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
|
||||
def http_error(status_code: int, message: str, code: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status_code,
|
||||
detail=ErrorOut(error=message, code=code).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
def error_code_for_status(status_code: int) -> str:
|
||||
if status_code == 404:
|
||||
return "not_found"
|
||||
if status_code == 401:
|
||||
return "unauthorized"
|
||||
if status_code == 403:
|
||||
return "forbidden"
|
||||
if status_code == 422:
|
||||
return "validation_error"
|
||||
if status_code >= 500:
|
||||
return "internal_error"
|
||||
return "error"
|
||||
|
||||
|
||||
def error_message_for_status(status_code: int, detail: str | None = None) -> str:
|
||||
if status_code == 404:
|
||||
return "Not found"
|
||||
if status_code == 401:
|
||||
return "Unauthorized"
|
||||
if status_code == 403:
|
||||
return "Forbidden"
|
||||
if status_code >= 500:
|
||||
return "Internal server error"
|
||||
if detail:
|
||||
return detail
|
||||
return "Request failed"
|
||||
|
||||
|
||||
def sanitize_detail(detail: object, status_code: int = 400) -> tuple[str, str]:
|
||||
if isinstance(detail, dict):
|
||||
if "error" in detail and "code" in detail:
|
||||
return str(detail["error"]), str(detail["code"])
|
||||
if "message" in detail:
|
||||
return str(detail["message"]), error_code_for_status(status_code)
|
||||
if isinstance(detail, list):
|
||||
return "Validation failed", "validation_error"
|
||||
if isinstance(detail, str):
|
||||
return error_message_for_status(status_code, detail), error_code_for_status(status_code)
|
||||
if detail is None:
|
||||
return error_message_for_status(status_code), error_code_for_status(status_code)
|
||||
return error_message_for_status(status_code, str(detail)), error_code_for_status(status_code)
|
||||
@@ -0,0 +1,57 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from devplacepy_services.base.config import service_url
|
||||
from devplacepy_services.base.http import internal_request
|
||||
from devplacepy_services.base.schemas import HealthOut, HealthStatsOut
|
||||
from devplacepy_services.base.service import BaseMicroservice
|
||||
from devplacepy_services.base.stats import COUNTERS
|
||||
|
||||
|
||||
async def _dep_status(name: str) -> str:
|
||||
url = f"{service_url(name)}/health"
|
||||
try:
|
||||
response = await internal_request("GET", url, timeout=2.0)
|
||||
if response.status_code == 200:
|
||||
payload = response.json()
|
||||
if payload.get("status") == "ok":
|
||||
return "ok"
|
||||
return "degraded"
|
||||
return "down"
|
||||
except Exception:
|
||||
return "down"
|
||||
|
||||
|
||||
def health_router(service: BaseMicroservice) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/health", response_model=HealthOut)
|
||||
async def health() -> HealthOut:
|
||||
deps: dict[str, str] = {}
|
||||
if service.depends_on:
|
||||
results = await asyncio.gather(
|
||||
*[_dep_status(dep) for dep in service.depends_on]
|
||||
)
|
||||
for dep_name, status in zip(service.depends_on, results, strict=True):
|
||||
deps[dep_name] = status
|
||||
overall = "ok"
|
||||
if deps and any(status != "ok" for status in deps.values()):
|
||||
overall = "degraded"
|
||||
uptime_s = int(time.monotonic() - service.started_at)
|
||||
stats = COUNTERS.snapshot()
|
||||
return HealthOut(
|
||||
service=service.name,
|
||||
status=overall,
|
||||
uptime_s=uptime_s,
|
||||
version=service.version,
|
||||
deps=deps,
|
||||
stats=HealthStatsOut(requests=stats["requests"], errors=stats["errors"]),
|
||||
)
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,77 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy import stealth
|
||||
|
||||
INTERNAL_CALLS: contextvars.ContextVar[int] = contextvars.ContextVar(
|
||||
"internal_calls", default=0
|
||||
)
|
||||
REQUEST_ID: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="")
|
||||
|
||||
_POOL: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
def bump_internal_calls() -> None:
|
||||
INTERNAL_CALLS.set(INTERNAL_CALLS.get() + 1)
|
||||
|
||||
|
||||
def current_internal_calls() -> int:
|
||||
return INTERNAL_CALLS.get()
|
||||
|
||||
|
||||
def current_request_id() -> str:
|
||||
return REQUEST_ID.get()
|
||||
|
||||
|
||||
async def startup_pool() -> None:
|
||||
global _POOL
|
||||
if _POOL is None:
|
||||
_POOL = httpx.AsyncClient(
|
||||
limits=httpx.Limits(max_connections=32, max_keepalive_connections=32),
|
||||
timeout=httpx.Timeout(30.0),
|
||||
http2=False,
|
||||
)
|
||||
|
||||
|
||||
async def shutdown_pool() -> None:
|
||||
global _POOL
|
||||
if _POOL is not None:
|
||||
await _POOL.aclose()
|
||||
_POOL = None
|
||||
|
||||
|
||||
def stealth_async_client(**kwargs: Any) -> httpx.AsyncClient:
|
||||
return stealth.stealth_async_client(**kwargs)
|
||||
|
||||
|
||||
async def internal_request(
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
json: dict | None = None,
|
||||
params: dict | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: float = 30.0,
|
||||
) -> httpx.Response:
|
||||
await startup_pool()
|
||||
assert _POOL is not None
|
||||
bump_internal_calls()
|
||||
merged: dict[str, str] = {}
|
||||
request_id = current_request_id()
|
||||
if request_id:
|
||||
merged["X-Request-Id"] = request_id
|
||||
internal_key = os.environ.get("DEVPLACE_GATEWAY_INTERNAL_KEY", "").strip()
|
||||
if internal_key:
|
||||
merged["X-Internal-Key"] = internal_key
|
||||
if headers:
|
||||
merged.update(headers)
|
||||
return await _POOL.request(
|
||||
method, url, json=json, params=params, headers=merged, timeout=timeout
|
||||
)
|
||||
@@ -0,0 +1,215 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
WorkersSpec = int | Literal["auto"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceSpec:
|
||||
name: str
|
||||
module: str
|
||||
port: int
|
||||
tier: int
|
||||
workers: WorkersSpec
|
||||
stateful: bool
|
||||
depends_on: tuple[str, ...]
|
||||
health_client_timeout: float = 2.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IngressRoute:
|
||||
prefix: str
|
||||
service: str
|
||||
websocket: bool = False
|
||||
|
||||
|
||||
SERVICE_SPECS: tuple[ServiceSpec, ...] = (
|
||||
ServiceSpec("database", "devplacepy_services.database.main:app", 10601, 1, 1, True, ()),
|
||||
ServiceSpec("pubsub", "devplacepy_services.pubsub.main:app", 10602, 1, 1, True, ("database",)),
|
||||
ServiceSpec(
|
||||
"web",
|
||||
"devplacepy_services.web.main:app",
|
||||
10500,
|
||||
2,
|
||||
"auto",
|
||||
False,
|
||||
("database", "pubsub"),
|
||||
health_client_timeout=4.0,
|
||||
),
|
||||
ServiceSpec(
|
||||
"gateway",
|
||||
"devplacepy_services.gateway.main:app",
|
||||
10620,
|
||||
2,
|
||||
"auto",
|
||||
False,
|
||||
("database",),
|
||||
health_client_timeout=4.0,
|
||||
),
|
||||
ServiceSpec(
|
||||
"jobs",
|
||||
"devplacepy_services.jobs.main:app",
|
||||
10630,
|
||||
3,
|
||||
1,
|
||||
True,
|
||||
("database", "pubsub", "gateway"),
|
||||
health_client_timeout=8.0,
|
||||
),
|
||||
ServiceSpec(
|
||||
"devii",
|
||||
"devplacepy_services.devii.main:app",
|
||||
10631,
|
||||
3,
|
||||
2,
|
||||
True,
|
||||
("database", "pubsub", "gateway"),
|
||||
health_client_timeout=4.0,
|
||||
),
|
||||
ServiceSpec("bot", "devplacepy_services.bot.main:app", 10632, 3, 1, True, ("database", "gateway")),
|
||||
ServiceSpec("backup", "devplacepy_services.backup.main:app", 10633, 3, 1, True, ("database",)),
|
||||
ServiceSpec(
|
||||
"containers",
|
||||
"devplacepy_services.containers.main:app",
|
||||
10634,
|
||||
3,
|
||||
1,
|
||||
True,
|
||||
("database",),
|
||||
health_client_timeout=4.0,
|
||||
),
|
||||
ServiceSpec("telegram", "devplacepy_services.telegram.main:app", 10635, 3, 1, True, ("database", "pubsub")),
|
||||
ServiceSpec("email", "devplacepy_services.email.main:app", 10636, 3, 1, True, ("database",)),
|
||||
ServiceSpec(
|
||||
"news",
|
||||
"devplacepy_services.news.main:app",
|
||||
10637,
|
||||
3,
|
||||
1,
|
||||
True,
|
||||
("database", "gateway"),
|
||||
),
|
||||
ServiceSpec("gitea", "devplacepy_services.gitea.main:app", 10638, 3, 1, True, ("database", "gateway")),
|
||||
ServiceSpec("audit", "devplacepy_services.audit.main:app", 10639, 3, 1, True, ("database",)),
|
||||
ServiceSpec("xmlrpc", "devplacepy_services.xmlrpc.main:app", 10649, 3, 1, True, ("database",)),
|
||||
)
|
||||
|
||||
SERVICES: dict[str, ServiceSpec] = {spec.name: spec for spec in SERVICE_SPECS}
|
||||
|
||||
TIER_ORDER: dict[int, tuple[str, ...]] = {
|
||||
1: ("database", "pubsub"),
|
||||
2: ("web", "gateway"),
|
||||
3: (
|
||||
"jobs",
|
||||
"devii",
|
||||
"bot",
|
||||
"backup",
|
||||
"containers",
|
||||
"telegram",
|
||||
"email",
|
||||
"news",
|
||||
"gitea",
|
||||
"audit",
|
||||
"xmlrpc",
|
||||
),
|
||||
}
|
||||
|
||||
SERVICE_URL_ENV: dict[str, str] = {
|
||||
"web": "DEVPLACE_WEB_URL",
|
||||
"database": "DEVPLACE_DB_SERVICE_URL",
|
||||
"pubsub": "DEVPLACE_PUBSUB_URL",
|
||||
"gateway": "DEVPLACE_GATEWAY_URL",
|
||||
"jobs": "DEVPLACE_JOBS_URL",
|
||||
"devii": "DEVPLACE_DEVII_URL",
|
||||
"bot": "DEVPLACE_BOT_URL",
|
||||
"backup": "DEVPLACE_BACKUP_URL",
|
||||
"containers": "DEVPLACE_CONTAINERS_URL",
|
||||
"telegram": "DEVPLACE_TELEGRAM_URL",
|
||||
"email": "DEVPLACE_EMAIL_URL",
|
||||
"news": "DEVPLACE_NEWS_URL",
|
||||
"gitea": "DEVPLACE_GITEA_URL",
|
||||
"audit": "DEVPLACE_AUDIT_URL",
|
||||
"xmlrpc": "DEVPLACE_XMLRPC_URL",
|
||||
}
|
||||
|
||||
INGRESS_ROUTES: tuple[IngressRoute, ...] = (
|
||||
IngressRoute("/openai", "gateway"),
|
||||
IngressRoute("/devii", "devii", websocket=True),
|
||||
IngressRoute("/pubsub", "pubsub", websocket=True),
|
||||
IngressRoute("/zips", "jobs"),
|
||||
IngressRoute("/forks", "jobs"),
|
||||
IngressRoute("/tools", "jobs", websocket=True),
|
||||
IngressRoute("/xmlrpc", "xmlrpc"),
|
||||
)
|
||||
|
||||
INGRESS_WS_PREFIXES: tuple[tuple[str, str], ...] = (
|
||||
(
|
||||
"/projects/",
|
||||
"containers",
|
||||
),
|
||||
)
|
||||
|
||||
FORBIDDEN_GAPS: tuple[tuple[int, int], ...] = (
|
||||
(10520, 10599),
|
||||
(10610, 10619),
|
||||
(10650, 10699),
|
||||
(10750, 10799),
|
||||
(20550, 20599),
|
||||
(20650, 20699),
|
||||
)
|
||||
|
||||
PORT_HINTS: dict[int, str] = {
|
||||
10500: "web service",
|
||||
20500: "test orchestrator",
|
||||
10502: "locust",
|
||||
}
|
||||
|
||||
_MICRO_DEV_PORTS: dict[str, int] = {spec.name: spec.port for spec in SERVICE_SPECS}
|
||||
|
||||
XMLRPC_RAW_SOCKET_PORT_DEV = 10648
|
||||
|
||||
|
||||
def xmlrpc_raw_socket_port(profile: str) -> int:
|
||||
if profile == "micro-test":
|
||||
return XMLRPC_RAW_SOCKET_PORT_DEV + 10000
|
||||
if profile == "micro-prod":
|
||||
return XMLRPC_RAW_SOCKET_PORT_DEV + 100
|
||||
return XMLRPC_RAW_SOCKET_PORT_DEV
|
||||
|
||||
|
||||
def build_profiles() -> dict[str, dict[str, int]]:
|
||||
return {
|
||||
"micro-dev": dict(_MICRO_DEV_PORTS),
|
||||
"micro-test": {
|
||||
k: (20500 if k == "web" else v + 10000) for k, v in _MICRO_DEV_PORTS.items()
|
||||
},
|
||||
"micro-prod": {
|
||||
k: (10500 if k == "web" else v + 100) for k, v in _MICRO_DEV_PORTS.items()
|
||||
},
|
||||
"locust": {"app": 10502, "ui": 10503},
|
||||
}
|
||||
|
||||
|
||||
def orchestrator_services_dict() -> dict[str, dict]:
|
||||
return {
|
||||
spec.name: {
|
||||
"module": spec.module,
|
||||
"port": spec.port,
|
||||
"tier": spec.tier,
|
||||
"workers": spec.workers,
|
||||
"stateful": spec.stateful,
|
||||
"depends_on": list(spec.depends_on),
|
||||
}
|
||||
for spec in SERVICE_SPECS
|
||||
}
|
||||
|
||||
|
||||
def health_client_timeout(name: str) -> float:
|
||||
spec = SERVICES.get(name)
|
||||
if spec is None:
|
||||
return 2.0
|
||||
return spec.health_client_timeout
|
||||
@@ -0,0 +1,121 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid_utils
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from devplacepy_services.base.auth import validate_internal_key
|
||||
from devplacepy_services.base.errors import error_response, sanitize_detail
|
||||
from devplacepy_services.base.http import INTERNAL_CALLS, REQUEST_ID, current_internal_calls
|
||||
from devplacepy_services.base.stats import COUNTERS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PUBLIC_PATHS = {"/health"}
|
||||
|
||||
|
||||
class RequestStatsMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
COUNTERS.record_request()
|
||||
response = await call_next(request)
|
||||
if response.status_code >= 500:
|
||||
COUNTERS.record_error()
|
||||
return response
|
||||
|
||||
|
||||
class InternalCallCounterMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
token_calls = INTERNAL_CALLS.set(0)
|
||||
response = await call_next(request)
|
||||
service_name = getattr(request.app.state, "service_name", "")
|
||||
if service_name in {"web", "gateway"}:
|
||||
response.headers["X-Internal-Calls"] = str(current_internal_calls())
|
||||
INTERNAL_CALLS.reset(token_calls)
|
||||
return response
|
||||
|
||||
|
||||
class InternalAuthMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
if path in PUBLIC_PATHS:
|
||||
return await call_next(request)
|
||||
if not validate_internal_key(request.headers.get("X-Internal-Key")):
|
||||
return error_response(401, "Unauthorized", "unauthorized")
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class SanitizeErrorsMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
request_id = request.headers.get("X-Request-Id") or uuid_utils.uuid7().hex
|
||||
token_id = REQUEST_ID.set(request_id)
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
COUNTERS.record_error()
|
||||
logger.exception(
|
||||
"unhandled service error",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"service": getattr(request.app.state, "service_name", ""),
|
||||
"path": request.url.path,
|
||||
},
|
||||
)
|
||||
response = error_response(500, "Internal server error", "internal_error")
|
||||
finally:
|
||||
REQUEST_ID.reset(token_id)
|
||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||||
service_name = getattr(request.app.state, "service_name", "")
|
||||
logger.info(
|
||||
"request",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"service": service_name,
|
||||
"path": request.url.path,
|
||||
"status": response.status_code,
|
||||
"duration_ms": duration_ms,
|
||||
"internal_calls": current_internal_calls(),
|
||||
},
|
||||
)
|
||||
if response.status_code < 400:
|
||||
return response
|
||||
if not hasattr(response, "body_iterator"):
|
||||
return response
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "application/json" not in content_type:
|
||||
if response.status_code == 404:
|
||||
return error_response(404, "Not found", "not_found")
|
||||
if response.status_code == 401:
|
||||
return error_response(401, "Unauthorized", "unauthorized")
|
||||
if response.status_code >= 500:
|
||||
return error_response(500, "Internal server error", "internal_error")
|
||||
return error_response(response.status_code, "Request failed", "error")
|
||||
body = b""
|
||||
async for chunk in response.body_iterator:
|
||||
body += chunk
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return error_response(response.status_code, "Request failed", "error")
|
||||
if isinstance(parsed, dict) and "error" in parsed and "code" in parsed:
|
||||
return Response(
|
||||
content=body,
|
||||
status_code=response.status_code,
|
||||
media_type="application/json",
|
||||
)
|
||||
if isinstance(parsed, dict) and isinstance(parsed.get("error"), dict) and "message" in parsed["error"]:
|
||||
return Response(
|
||||
content=body,
|
||||
status_code=response.status_code,
|
||||
media_type="application/json",
|
||||
)
|
||||
if isinstance(parsed, dict) and "detail" in parsed:
|
||||
message, code = sanitize_detail(parsed["detail"], response.status_code)
|
||||
return error_response(response.status_code, message, code)
|
||||
return error_response(response.status_code, "Request failed", "error")
|
||||
@@ -0,0 +1,37 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
HOP_HEADERS = frozenset(
|
||||
{
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"host",
|
||||
"content-length",
|
||||
"content-encoding",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def forward_headers(request: Request, prefix: str) -> dict[str, str]:
|
||||
headers = {
|
||||
k: v for k, v in request.headers.items() if k.lower() not in HOP_HEADERS
|
||||
}
|
||||
headers["X-Forwarded-Prefix"] = prefix
|
||||
headers["X-Script-Name"] = prefix
|
||||
headers["X-Forwarded-Host"] = request.headers.get(
|
||||
"host", request.url.hostname or ""
|
||||
)
|
||||
headers["X-Forwarded-Proto"] = request.headers.get(
|
||||
"x-forwarded-proto", request.url.scheme
|
||||
)
|
||||
headers["Accept-Encoding"] = "identity"
|
||||
return headers
|
||||
@@ -0,0 +1,13 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from devplacepy_services.base.config import service_url
|
||||
from devplacepy_services.base.http import internal_request
|
||||
|
||||
|
||||
async def publish(topic: str, data: dict[str, Any]) -> None:
|
||||
url = f"{service_url('pubsub')}/internal/publish"
|
||||
await internal_request("POST", url, json={"topic": topic, "data": data})
|
||||
@@ -0,0 +1,29 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ErrorOut(BaseModel):
|
||||
error: str
|
||||
code: str
|
||||
|
||||
|
||||
class HealthStatsOut(BaseModel):
|
||||
requests: int
|
||||
errors: int
|
||||
|
||||
|
||||
class HealthOut(BaseModel):
|
||||
service: str
|
||||
status: str
|
||||
uptime_s: int
|
||||
version: str
|
||||
deps: dict[str, str]
|
||||
stats: HealthStatsOut
|
||||
|
||||
|
||||
class InternalUser(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
uid: str | None = None
|
||||
@@ -0,0 +1,134 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.exceptions import HTTPException
|
||||
|
||||
from devplacepy_services.base.config import PROFILES, port_profile
|
||||
from devplacepy_services.base.errors import error_response, sanitize_detail
|
||||
from devplacepy_services.base.http import shutdown_pool, startup_pool
|
||||
from devplacepy_services.base.middleware import (
|
||||
InternalAuthMiddleware,
|
||||
InternalCallCounterMiddleware,
|
||||
RequestStatsMiddleware,
|
||||
SanitizeErrorsMiddleware,
|
||||
)
|
||||
|
||||
|
||||
class BaseMicroservice:
|
||||
name: str = ""
|
||||
title: str = ""
|
||||
default_port: int = 0
|
||||
workers: int | Literal["auto"] = 1
|
||||
stateful: bool = True
|
||||
depends_on: list[str] = []
|
||||
managed_services: list = []
|
||||
use_background: bool = False
|
||||
run_supervisor: bool = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.started_at = time.monotonic()
|
||||
self.version = self._resolve_version()
|
||||
|
||||
def _resolve_version(self) -> str:
|
||||
env_version = os.environ.get("DEVPLACE_VERSION", "").strip()
|
||||
if env_version:
|
||||
return env_version
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip() or "unknown"
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
return "unknown"
|
||||
|
||||
def resolved_port(self) -> int:
|
||||
if self.default_port:
|
||||
return self.default_port
|
||||
profile = port_profile()
|
||||
return PROFILES[profile][self.name]
|
||||
|
||||
def apply_base_middleware(self, app: FastAPI, *, internal_auth: bool = False) -> None:
|
||||
app.add_middleware(SanitizeErrorsMiddleware)
|
||||
if internal_auth:
|
||||
app.add_middleware(InternalAuthMiddleware)
|
||||
app.add_middleware(InternalCallCounterMiddleware)
|
||||
app.add_middleware(RequestStatsMiddleware)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(self, app: FastAPI):
|
||||
app.state.service_name = self.name
|
||||
self.started_at = time.monotonic()
|
||||
await startup_pool()
|
||||
background = None
|
||||
if self.use_background:
|
||||
from devplacepy.services.background import background
|
||||
|
||||
await background.start()
|
||||
background = background
|
||||
if self.managed_services:
|
||||
from devplacepy.services.manager import service_manager
|
||||
|
||||
for svc in self.managed_services:
|
||||
service_manager.register(svc)
|
||||
service_manager.set_lock_owner(True)
|
||||
if self.run_supervisor:
|
||||
import asyncio
|
||||
|
||||
asyncio.get_running_loop().call_soon(service_manager.supervise)
|
||||
yield
|
||||
if self.managed_services:
|
||||
from devplacepy.services.manager import service_manager
|
||||
|
||||
await service_manager.shutdown_all()
|
||||
if background is not None:
|
||||
await background.stop()
|
||||
await shutdown_pool()
|
||||
|
||||
def create_app(self) -> FastAPI:
|
||||
app = FastAPI(title=self.title, lifespan=self.lifespan)
|
||||
app.state.service_name = self.name
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(_request, exc: HTTPException):
|
||||
if isinstance(exc.detail, dict) and "error" in exc.detail and "code" in exc.detail:
|
||||
return error_response(exc.status_code, exc.detail["error"], exc.detail["code"])
|
||||
message, code = sanitize_detail(exc.detail, exc.status_code)
|
||||
return error_response(exc.status_code, message, code)
|
||||
|
||||
return app
|
||||
|
||||
def build_app(self) -> FastAPI:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def build_standard_app(
|
||||
service: BaseMicroservice,
|
||||
*,
|
||||
routers: list[tuple[APIRouter, str] | tuple[APIRouter]] | None = None,
|
||||
internal_auth: bool = False,
|
||||
) -> FastAPI:
|
||||
from devplacepy_services.base.health import health_router
|
||||
|
||||
app = service.create_app()
|
||||
service.apply_base_middleware(app, internal_auth=internal_auth)
|
||||
app.include_router(health_router(service))
|
||||
for entry in routers or []:
|
||||
if len(entry) == 2:
|
||||
app.include_router(entry[0], prefix=entry[1])
|
||||
else:
|
||||
app.include_router(entry[0])
|
||||
return app
|
||||
@@ -0,0 +1,110 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeVar
|
||||
|
||||
import dataset
|
||||
|
||||
from devplacepy_services.base.config import sqlite_read_pool_size
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
PRAGMAS = [
|
||||
"PRAGMA journal_mode=WAL",
|
||||
"PRAGMA synchronous=NORMAL",
|
||||
"PRAGMA busy_timeout=30000",
|
||||
"PRAGMA cache_size=-8000",
|
||||
"PRAGMA temp_store=MEMORY",
|
||||
"PRAGMA mmap_size=268435456",
|
||||
]
|
||||
|
||||
|
||||
class SQLiteFileBroker:
|
||||
name: str
|
||||
path: Path
|
||||
read_pool_size: int
|
||||
|
||||
def __init__(self, name: str, path: Path, read_pool_size: int | None = None) -> None:
|
||||
self.name = name
|
||||
self.path = Path(path)
|
||||
self.read_pool_size = read_pool_size or sqlite_read_pool_size()
|
||||
self._write_db: Any = None
|
||||
self._read_pool: asyncio.Queue[Any] | None = None
|
||||
self._write_queue: asyncio.Queue[Any] | None = None
|
||||
self._write_worker_task: asyncio.Task | None = None
|
||||
|
||||
def _connect_rw(self):
|
||||
return dataset.connect(
|
||||
f"sqlite:///{self.path}",
|
||||
engine_kwargs={
|
||||
"connect_args": {
|
||||
"timeout": 30,
|
||||
"check_same_thread": False,
|
||||
},
|
||||
},
|
||||
on_connect_statements=PRAGMAS,
|
||||
)
|
||||
|
||||
def _connect_ro(self):
|
||||
abs_path = self.path.resolve().as_posix()
|
||||
return dataset.connect(
|
||||
f"sqlite:///file:{abs_path}?uri=true&mode=ro",
|
||||
engine_kwargs={
|
||||
"connect_args": {
|
||||
"timeout": 30,
|
||||
"check_same_thread": False,
|
||||
"uri": True,
|
||||
},
|
||||
},
|
||||
on_connect_statements=PRAGMAS,
|
||||
)
|
||||
|
||||
async def startup(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._write_db = self._connect_rw()
|
||||
self._read_pool = asyncio.Queue()
|
||||
for _ in range(self.read_pool_size):
|
||||
await self._read_pool.put(self._connect_ro())
|
||||
self._write_queue = asyncio.Queue()
|
||||
self._write_worker_task = asyncio.create_task(self._write_worker())
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self._write_worker_task is not None:
|
||||
self._write_worker_task.cancel()
|
||||
try:
|
||||
await self._write_worker_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._write_worker_task = None
|
||||
|
||||
async def read(self, fn: Callable[..., T], *args, **kwargs) -> T:
|
||||
assert self._read_pool is not None
|
||||
db = await self._read_pool.get()
|
||||
try:
|
||||
return await asyncio.to_thread(fn, db, *args, **kwargs)
|
||||
finally:
|
||||
await self._read_pool.put(db)
|
||||
|
||||
async def write(self, fn: Callable[..., T], *args, **kwargs) -> T:
|
||||
assert self._write_queue is not None
|
||||
loop = asyncio.get_running_loop()
|
||||
future = loop.create_future()
|
||||
await self._write_queue.put((fn, args, kwargs, future))
|
||||
return await future
|
||||
|
||||
async def _write_worker(self) -> None:
|
||||
assert self._write_queue is not None
|
||||
while True:
|
||||
fn, args, kwargs, future = await self._write_queue.get()
|
||||
try:
|
||||
result = fn(self._write_db, *args, **kwargs)
|
||||
future.set_result(result)
|
||||
except Exception as exc:
|
||||
future.set_exception(exc)
|
||||
|
||||
async def read_after_write(self, fn: Callable[..., T], *args, **kwargs) -> T:
|
||||
return await asyncio.to_thread(fn, self._write_db, *args, **kwargs)
|
||||
@@ -0,0 +1,26 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import threading
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestCounters:
|
||||
requests: int = 0
|
||||
errors: int = 0
|
||||
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
||||
|
||||
def record_request(self) -> None:
|
||||
with self._lock:
|
||||
self.requests += 1
|
||||
|
||||
def record_error(self) -> None:
|
||||
with self._lock:
|
||||
self.errors += 1
|
||||
|
||||
def snapshot(self) -> dict[str, int]:
|
||||
with self._lock:
|
||||
return {"requests": self.requests, "errors": self.errors}
|
||||
|
||||
|
||||
COUNTERS = RequestCounters()
|
||||
@@ -0,0 +1,40 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from devplacepy_services.base.auth import set_internal_gateway_key
|
||||
|
||||
|
||||
class ServiceTestClient:
|
||||
def __init__(
|
||||
self,
|
||||
app: FastAPI,
|
||||
*,
|
||||
internal_key: str = "test-internal-key",
|
||||
authenticated_user: str | None = None,
|
||||
) -> None:
|
||||
set_internal_gateway_key(internal_key)
|
||||
headers: dict[str, str] = {"X-Internal-Key": internal_key}
|
||||
if authenticated_user:
|
||||
headers["X-Authenticated-User"] = authenticated_user
|
||||
self._client = TestClient(app, headers=headers)
|
||||
|
||||
def get(self, path: str, **kwargs: Any):
|
||||
return self._client.get(path, **kwargs)
|
||||
|
||||
def post(self, path: str, **kwargs: Any):
|
||||
return self._client.post(path, **kwargs)
|
||||
|
||||
def put(self, path: str, **kwargs: Any):
|
||||
return self._client.put(path, **kwargs)
|
||||
|
||||
def delete(self, path: str, **kwargs: Any):
|
||||
return self._client.delete(path, **kwargs)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
Reference in New Issue
Block a user