update
DevPlace CI / test (push) Has been cancelled

This commit is contained in:
2026-07-23 01:15:04 +02:00
parent 582e37d176
commit b534a496fd
79 changed files with 4086 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# retoor <retoor@molodetz.nl>
from devplacepy_services.base.service import BaseMicroservice
__all__ = ["BaseMicroservice"]
+61
View File
@@ -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)
+4
View File
@@ -0,0 +1,4 @@
import os
os.environ["DEVPLACE_REMOTE_DB"] = "1"
import devplacepy.db_client
+67
View File
@@ -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
+153
View File
@@ -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
+70
View File
@@ -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]
+236
View File
@@ -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())
+126
View File
@@ -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)
+63
View File
@@ -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)
+57
View File
@@ -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
+77
View File
@@ -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
)
+215
View File
@@ -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
+121
View File
@@ -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")
+37
View File
@@ -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
+13
View File
@@ -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})
+29
View File
@@ -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
+134
View File
@@ -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
+110
View File
@@ -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)
+26
View File
@@ -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()
+40
View File
@@ -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()