feat: replace REST API with gRPC service

Complete migration from FastAPI/HTTP to gRPC:

Proto definition (proto/daena/v1/daena.proto):
- Daena service with 9 RPCs: GetStatus, GetHealth, GetConfig,
  ListPlugins, GetQueueState, ListPendingRecords, ListDeadRecords,
  ListSinks, ReloadConfig
- Messages for all request/response types with proper field types

gRPC server (src/daena/api/grpc_server.py):
- Async gRPC aio server with DaenaServicer implementation
- DaenaGRPCServer with start/stop/serve_forever lifecycle
- All endpoints delegate to DaenaRuntime

gRPC client (src/daena/cli/main.py):
- CLI now uses grpc.insecure_channel + DaenaStub
- Same commands: status, health, queue, sinks, plugins, config, version

Config changes:
- Remove FastAPI/uvicorn/http config, replace with grpc config
- GRPCConfig with host/port/max_workers

Dependency changes:
- Remove fastapi, uvicorn, starlette, anyio
- Add grpcio, grpcio-tools, protobuf
- Keep httpx for HTTP sink forwarding (separate from management API)

Tests:
- 8 new gRPC integration tests (test_grpc.py)
- All 31 tests passing, ruff clean
This commit is contained in:
ahp
2026-07-15 10:48:18 +03:30
parent b3243427b7
commit 4da9accabf
21 changed files with 1019 additions and 324 deletions
+2 -2
View File
@@ -10,10 +10,10 @@ logging:
format: json
api:
http:
grpc:
host: 127.0.0.1
port: 8642
workers: 1
max_workers: 10
pipeline:
batch_size: 100
-1
View File
@@ -14,7 +14,6 @@ ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
AmbientCapabilities=CAP_SYS_PTRACE
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
+96
View File
@@ -0,0 +1,96 @@
syntax = "proto3";
package daena.v1;
service Daena {
rpc GetStatus(Empty) returns (StatusResponse);
rpc GetHealth(Empty) returns (HealthResponse);
rpc GetConfig(Empty) returns (ConfigResponse);
rpc ListPlugins(Empty) returns (PluginListResponse);
rpc GetQueueState(Empty) returns (QueueStateResponse);
rpc ListPendingRecords(Pagination) returns (RecordList);
rpc ListDeadRecords(Pagination) returns (RecordList);
rpc ListSinks(Empty) returns (SinkList);
rpc ReloadConfig(Empty) returns (ReloadResponse);
}
message Empty {}
message StatusResponse {
string service = 1;
string version = 2;
bool running = 3;
}
message HealthResponse {
bool healthy = 1;
bool running = 2;
bool backend = 3;
bool plugins_loaded = 4;
}
message ConfigResponse {
string config_json = 1;
}
message PluginInfo {
string name = 1;
string class_name = 2;
}
message PluginCategory {
repeated PluginInfo items = 1;
}
message PluginListResponse {
PluginCategory sources = 1;
PluginCategory processors = 2;
PluginCategory sinks = 3;
}
message QueueStateResponse {
int64 pending = 1;
int64 delivered = 2;
int64 failed = 3;
int64 dead = 4;
int64 total = 5;
}
message Pagination {
int32 limit = 1;
int32 offset = 2;
}
message RecordData {
string record_id = 1;
string source = 2;
string record_type = 3;
string body = 4;
string metadata = 5;
string state = 6;
string created_at = 7;
string updated_at = 8;
int32 retry_count = 9;
string next_retry_at = 10;
string last_error = 11;
string destination = 12;
}
message RecordList {
repeated RecordData records = 1;
int32 count = 2;
}
message SinkInfo {
string name = 1;
string type = 2;
}
message SinkList {
repeated SinkInfo sinks = 1;
}
message ReloadResponse {
bool success = 1;
string message = 2;
}
+3 -2
View File
@@ -21,8 +21,9 @@ classifiers = [
]
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"grpcio>=1.70",
"grpcio-tools>=1.70",
"protobuf>=5.28",
"pydantic>=2.0",
"pydantic-settings>=2.0",
"PyYAML>=6.0",
+1
View File
@@ -0,0 +1 @@
"""Daena gRPC API."""
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
import json
import grpc
import grpc.aio
from daena.api.pb2 import daena_pb2 as pb2
from daena.api.pb2 import daena_pb2_grpc as pb2_grpc
from daena.core.runtime import DaenaRuntime
from daena.logutil import get_logger
from daena.persistence.models import RecordState
log = get_logger("daena.grpc")
def _record_to_proto(record) -> pb2.RecordData:
return pb2.RecordData(
record_id=record.record_id,
source=record.source,
record_type=record.record_type,
body=json.dumps(record.body),
metadata=json.dumps(record.metadata),
state=record.state.value if hasattr(record.state, "value") else record.state,
created_at=record.created_at or "",
updated_at=record.updated_at or "",
retry_count=record.retry_count,
next_retry_at=record.next_retry_at or "",
last_error=record.last_error or "",
destination=record.destination or "",
)
# ruff: noqa: N802, ARG002
class DaenaServicer(pb2_grpc.DaenaServicer):
def __init__(self, runtime: DaenaRuntime) -> None:
self._runtime = runtime
async def GetStatus(self, request, context):
return pb2.StatusResponse(
service="daena",
version="0.1.0",
running=self._runtime._running,
)
async def GetHealth(self, request, context):
return pb2.HealthResponse(
healthy=self._runtime._running,
running=self._runtime._running,
backend=self._runtime.backend is not None,
plugins_loaded=bool(self._runtime.registry.list_sources()),
)
async def GetConfig(self, request, context):
cfg = self._runtime.config
raw = {
"data_dir": cfg.data_dir,
"log_dir": cfg.log_dir,
"api": {"grpc": {"host": cfg.api.grpc.host, "port": cfg.api.grpc.port}},
"pipeline": cfg.pipeline.model_dump(),
"persistence": cfg.persistence.model_dump(),
"sources": [s.model_dump() for s in cfg.sources],
"processors": [p.model_dump() for p in cfg.processors],
"sinks": [s.model_dump() for s in cfg.sinks],
}
return pb2.ConfigResponse(config_json=json.dumps(raw))
async def ListPlugins(self, request, context):
registry = self._runtime.registry
all_plugins = registry.all_plugins()
def _cat(key: str):
items = all_plugins.get(key, {})
return pb2.PluginCategory(
items=[pb2.PluginInfo(name=n, class_name=cls.__name__) for n, cls in items.items()]
)
return pb2.PluginListResponse(
sources=_cat("sources"),
processors=_cat("processors"),
sinks=_cat("sinks"),
)
async def GetQueueState(self, request, context):
backend = self._runtime.backend
if backend is None:
return pb2.QueueStateResponse()
return pb2.QueueStateResponse(
pending=backend.count(RecordState.pending),
delivered=backend.count(RecordState.delivered),
failed=backend.count(RecordState.failed),
dead=backend.count(RecordState.dead),
total=backend.count(),
)
async def ListPendingRecords(self, request, context):
backend = self._runtime.backend
if backend is None:
return pb2.RecordList(records=[], count=0)
records = backend.list_by_state(
RecordState.pending,
limit=request.limit or 100,
offset=request.offset or 0,
)
return pb2.RecordList(
records=[_record_to_proto(r) for r in records],
count=len(records),
)
async def ListDeadRecords(self, request, context):
backend = self._runtime.backend
if backend is None:
return pb2.RecordList(records=[], count=0)
records = backend.list_by_state(
RecordState.dead,
limit=request.limit or 100,
offset=request.offset or 0,
)
return pb2.RecordList(
records=[_record_to_proto(r) for r in records],
count=len(records),
)
async def ListSinks(self, request, context):
pipeline = self._runtime.pipeline
if pipeline is None:
return pb2.SinkList(sinks=[])
return pb2.SinkList(
sinks=[pb2.SinkInfo(name=s.name, type=type(s).__name__) for s in pipeline.sinks]
)
async def ReloadConfig(self, request, context):
try:
await self._runtime.reload()
return pb2.ReloadResponse(success=True, message="Configuration reloaded")
except Exception as exc:
return pb2.ReloadResponse(success=False, message=str(exc))
class DaenaGRPCServer:
"""Manages the gRPC server lifecycle."""
def __init__(self, runtime: DaenaRuntime, host: str = "127.0.0.1", port: int = 8642) -> None:
self._runtime = runtime
self._host = host
self._port = port
self._server: grpc.aio.Server | None = None
async def start(self) -> None:
self._server = grpc.aio.server()
servicer = DaenaServicer(self._runtime)
pb2_grpc.add_DaenaServicer_to_server(servicer, self._server)
address = f"{self._host}:{self._port}"
self._server.add_insecure_port(address)
await self._server.start()
log.info("grpc_server_started", address=address)
async def stop(self) -> None:
if self._server:
await self._server.stop(grace=5)
log.info("grpc_server_stopped")
async def serve_forever(self) -> None:
if self._server:
await self._server.wait_for_termination()
+1
View File
@@ -0,0 +1 @@
"""gRPC protocol buffer stubs for Daena v1."""
+62
View File
@@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
# ruff: noqa: I001, E501, UP009
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: daena/v1/daena.proto
# Protobuf Python Version: 7.35.0
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC, 7, 35, 0, "", "daena/v1/daena.proto"
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(
b'\n\x14\x64\x61\x65na/v1/daena.proto\x12\x08\x64\x61\x65na.v1"\x07\n\x05\x45mpty"C\n\x0eStatusResponse\x12\x0f\n\x07service\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t\x12\x0f\n\x07running\x18\x03 \x01(\x08"[\n\x0eHealthResponse\x12\x0f\n\x07healthy\x18\x01 \x01(\x08\x12\x0f\n\x07running\x18\x02 \x01(\x08\x12\x0f\n\x07\x62\x61\x63kend\x18\x03 \x01(\x08\x12\x16\n\x0eplugins_loaded\x18\x04 \x01(\x08"%\n\x0e\x43onfigResponse\x12\x13\n\x0b\x63onfig_json\x18\x01 \x01(\t".\n\nPluginInfo\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\nclass_name\x18\x02 \x01(\t"5\n\x0ePluginCategory\x12#\n\x05items\x18\x01 \x03(\x0b\x32\x14.daena.v1.PluginInfo"\x96\x01\n\x12PluginListResponse\x12)\n\x07sources\x18\x01 \x01(\x0b\x32\x18.daena.v1.PluginCategory\x12,\n\nprocessors\x18\x02 \x01(\x0b\x32\x18.daena.v1.PluginCategory\x12\'\n\x05sinks\x18\x03 \x01(\x0b\x32\x18.daena.v1.PluginCategory"e\n\x12QueueStateResponse\x12\x0f\n\x07pending\x18\x01 \x01(\x03\x12\x11\n\tdelivered\x18\x02 \x01(\x03\x12\x0e\n\x06\x66\x61iled\x18\x03 \x01(\x03\x12\x0c\n\x04\x64\x65\x61\x64\x18\x04 \x01(\x03\x12\r\n\x05total\x18\x05 \x01(\x03"+\n\nPagination\x12\r\n\x05limit\x18\x01 \x01(\x05\x12\x0e\n\x06offset\x18\x02 \x01(\x05"\xf0\x01\n\nRecordData\x12\x11\n\trecord_id\x18\x01 \x01(\t\x12\x0e\n\x06source\x18\x02 \x01(\t\x12\x13\n\x0brecord_type\x18\x03 \x01(\t\x12\x0c\n\x04\x62ody\x18\x04 \x01(\t\x12\x10\n\x08metadata\x18\x05 \x01(\t\x12\r\n\x05state\x18\x06 \x01(\t\x12\x12\n\ncreated_at\x18\x07 \x01(\t\x12\x12\n\nupdated_at\x18\x08 \x01(\t\x12\x13\n\x0bretry_count\x18\t \x01(\x05\x12\x15\n\rnext_retry_at\x18\n \x01(\t\x12\x12\n\nlast_error\x18\x0b \x01(\t\x12\x13\n\x0b\x64\x65stination\x18\x0c \x01(\t"B\n\nRecordList\x12%\n\x07records\x18\x01 \x03(\x0b\x32\x14.daena.v1.RecordData\x12\r\n\x05\x63ount\x18\x02 \x01(\x05"&\n\x08SinkInfo\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t"-\n\x08SinkList\x12!\n\x05sinks\x18\x01 \x03(\x0b\x32\x12.daena.v1.SinkInfo"2\n\x0eReloadResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t2\x9b\x04\n\x05\x44\x61\x65na\x12\x36\n\tGetStatus\x12\x0f.daena.v1.Empty\x1a\x18.daena.v1.StatusResponse\x12\x36\n\tGetHealth\x12\x0f.daena.v1.Empty\x1a\x18.daena.v1.HealthResponse\x12\x36\n\tGetConfig\x12\x0f.daena.v1.Empty\x1a\x18.daena.v1.ConfigResponse\x12<\n\x0bListPlugins\x12\x0f.daena.v1.Empty\x1a\x1c.daena.v1.PluginListResponse\x12>\n\rGetQueueState\x12\x0f.daena.v1.Empty\x1a\x1c.daena.v1.QueueStateResponse\x12@\n\x12ListPendingRecords\x12\x14.daena.v1.Pagination\x1a\x14.daena.v1.RecordList\x12=\n\x0fListDeadRecords\x12\x14.daena.v1.Pagination\x1a\x14.daena.v1.RecordList\x12\x30\n\tListSinks\x12\x0f.daena.v1.Empty\x1a\x12.daena.v1.SinkList\x12\x39\n\x0cReloadConfig\x12\x0f.daena.v1.Empty\x1a\x18.daena.v1.ReloadResponseb\x06proto3'
)
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "daena.v1.daena_pb2", _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals["_EMPTY"]._serialized_start = 34
_globals["_EMPTY"]._serialized_end = 41
_globals["_STATUSRESPONSE"]._serialized_start = 43
_globals["_STATUSRESPONSE"]._serialized_end = 110
_globals["_HEALTHRESPONSE"]._serialized_start = 112
_globals["_HEALTHRESPONSE"]._serialized_end = 203
_globals["_CONFIGRESPONSE"]._serialized_start = 205
_globals["_CONFIGRESPONSE"]._serialized_end = 242
_globals["_PLUGININFO"]._serialized_start = 244
_globals["_PLUGININFO"]._serialized_end = 290
_globals["_PLUGINCATEGORY"]._serialized_start = 292
_globals["_PLUGINCATEGORY"]._serialized_end = 345
_globals["_PLUGINLISTRESPONSE"]._serialized_start = 348
_globals["_PLUGINLISTRESPONSE"]._serialized_end = 498
_globals["_QUEUESTATERESPONSE"]._serialized_start = 500
_globals["_QUEUESTATERESPONSE"]._serialized_end = 601
_globals["_PAGINATION"]._serialized_start = 603
_globals["_PAGINATION"]._serialized_end = 646
_globals["_RECORDDATA"]._serialized_start = 649
_globals["_RECORDDATA"]._serialized_end = 889
_globals["_RECORDLIST"]._serialized_start = 891
_globals["_RECORDLIST"]._serialized_end = 957
_globals["_SINKINFO"]._serialized_start = 959
_globals["_SINKINFO"]._serialized_end = 997
_globals["_SINKLIST"]._serialized_start = 999
_globals["_SINKLIST"]._serialized_end = 1044
_globals["_RELOADRESPONSE"]._serialized_start = 1046
_globals["_RELOADRESPONSE"]._serialized_end = 1096
_globals["_DAENA"]._serialized_start = 1099
_globals["_DAENA"]._serialized_end = 1638
# @@protoc_insertion_point(module_scope)
+479
View File
@@ -0,0 +1,479 @@
# ruff: noqa: N802, ARG002, I001, F401, E501
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import warnings
from daena.api.pb2 import daena_pb2 as daena_dot_v1_dot_daena__pb2
GRPC_GENERATED_VERSION = "1.82.1"
GRPC_VERSION = grpc.__version__
_version_not_supported = False
try:
from grpc._utilities import first_version_is_lower
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
except ImportError:
_version_not_supported = True
if _version_not_supported:
raise RuntimeError(
f"The grpc package installed is at version {GRPC_VERSION},"
+ " but the generated code in daena/v1/daena_pb2_grpc.py depends on"
+ f" grpcio>={GRPC_GENERATED_VERSION}."
+ f" Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}"
+ f" or downgrade your generated code using grpcio-tools<={GRPC_VERSION}."
)
class DaenaStub:
"""Missing associated documentation comment in .proto file."""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.GetStatus = channel.unary_unary(
"/daena.v1.Daena/GetStatus",
request_serializer=daena_dot_v1_dot_daena__pb2.Empty.SerializeToString,
response_deserializer=daena_dot_v1_dot_daena__pb2.StatusResponse.FromString,
_registered_method=True,
)
self.GetHealth = channel.unary_unary(
"/daena.v1.Daena/GetHealth",
request_serializer=daena_dot_v1_dot_daena__pb2.Empty.SerializeToString,
response_deserializer=daena_dot_v1_dot_daena__pb2.HealthResponse.FromString,
_registered_method=True,
)
self.GetConfig = channel.unary_unary(
"/daena.v1.Daena/GetConfig",
request_serializer=daena_dot_v1_dot_daena__pb2.Empty.SerializeToString,
response_deserializer=daena_dot_v1_dot_daena__pb2.ConfigResponse.FromString,
_registered_method=True,
)
self.ListPlugins = channel.unary_unary(
"/daena.v1.Daena/ListPlugins",
request_serializer=daena_dot_v1_dot_daena__pb2.Empty.SerializeToString,
response_deserializer=daena_dot_v1_dot_daena__pb2.PluginListResponse.FromString,
_registered_method=True,
)
self.GetQueueState = channel.unary_unary(
"/daena.v1.Daena/GetQueueState",
request_serializer=daena_dot_v1_dot_daena__pb2.Empty.SerializeToString,
response_deserializer=daena_dot_v1_dot_daena__pb2.QueueStateResponse.FromString,
_registered_method=True,
)
self.ListPendingRecords = channel.unary_unary(
"/daena.v1.Daena/ListPendingRecords",
request_serializer=daena_dot_v1_dot_daena__pb2.Pagination.SerializeToString,
response_deserializer=daena_dot_v1_dot_daena__pb2.RecordList.FromString,
_registered_method=True,
)
self.ListDeadRecords = channel.unary_unary(
"/daena.v1.Daena/ListDeadRecords",
request_serializer=daena_dot_v1_dot_daena__pb2.Pagination.SerializeToString,
response_deserializer=daena_dot_v1_dot_daena__pb2.RecordList.FromString,
_registered_method=True,
)
self.ListSinks = channel.unary_unary(
"/daena.v1.Daena/ListSinks",
request_serializer=daena_dot_v1_dot_daena__pb2.Empty.SerializeToString,
response_deserializer=daena_dot_v1_dot_daena__pb2.SinkList.FromString,
_registered_method=True,
)
self.ReloadConfig = channel.unary_unary(
"/daena.v1.Daena/ReloadConfig",
request_serializer=daena_dot_v1_dot_daena__pb2.Empty.SerializeToString,
response_deserializer=daena_dot_v1_dot_daena__pb2.ReloadResponse.FromString,
_registered_method=True,
)
class DaenaServicer:
"""Missing associated documentation comment in .proto file."""
def GetStatus(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def GetHealth(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def GetConfig(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def ListPlugins(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def GetQueueState(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def ListPendingRecords(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def ListDeadRecords(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def ListSinks(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def ReloadConfig(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def add_DaenaServicer_to_server(servicer, server):
rpc_method_handlers = {
"GetStatus": grpc.unary_unary_rpc_method_handler(
servicer.GetStatus,
request_deserializer=daena_dot_v1_dot_daena__pb2.Empty.FromString,
response_serializer=daena_dot_v1_dot_daena__pb2.StatusResponse.SerializeToString,
),
"GetHealth": grpc.unary_unary_rpc_method_handler(
servicer.GetHealth,
request_deserializer=daena_dot_v1_dot_daena__pb2.Empty.FromString,
response_serializer=daena_dot_v1_dot_daena__pb2.HealthResponse.SerializeToString,
),
"GetConfig": grpc.unary_unary_rpc_method_handler(
servicer.GetConfig,
request_deserializer=daena_dot_v1_dot_daena__pb2.Empty.FromString,
response_serializer=daena_dot_v1_dot_daena__pb2.ConfigResponse.SerializeToString,
),
"ListPlugins": grpc.unary_unary_rpc_method_handler(
servicer.ListPlugins,
request_deserializer=daena_dot_v1_dot_daena__pb2.Empty.FromString,
response_serializer=daena_dot_v1_dot_daena__pb2.PluginListResponse.SerializeToString,
),
"GetQueueState": grpc.unary_unary_rpc_method_handler(
servicer.GetQueueState,
request_deserializer=daena_dot_v1_dot_daena__pb2.Empty.FromString,
response_serializer=daena_dot_v1_dot_daena__pb2.QueueStateResponse.SerializeToString,
),
"ListPendingRecords": grpc.unary_unary_rpc_method_handler(
servicer.ListPendingRecords,
request_deserializer=daena_dot_v1_dot_daena__pb2.Pagination.FromString,
response_serializer=daena_dot_v1_dot_daena__pb2.RecordList.SerializeToString,
),
"ListDeadRecords": grpc.unary_unary_rpc_method_handler(
servicer.ListDeadRecords,
request_deserializer=daena_dot_v1_dot_daena__pb2.Pagination.FromString,
response_serializer=daena_dot_v1_dot_daena__pb2.RecordList.SerializeToString,
),
"ListSinks": grpc.unary_unary_rpc_method_handler(
servicer.ListSinks,
request_deserializer=daena_dot_v1_dot_daena__pb2.Empty.FromString,
response_serializer=daena_dot_v1_dot_daena__pb2.SinkList.SerializeToString,
),
"ReloadConfig": grpc.unary_unary_rpc_method_handler(
servicer.ReloadConfig,
request_deserializer=daena_dot_v1_dot_daena__pb2.Empty.FromString,
response_serializer=daena_dot_v1_dot_daena__pb2.ReloadResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler("daena.v1.Daena", rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers("daena.v1.Daena", rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class Daena:
"""Missing associated documentation comment in .proto file."""
@staticmethod
def GetStatus(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/daena.v1.Daena/GetStatus",
daena_dot_v1_dot_daena__pb2.Empty.SerializeToString,
daena_dot_v1_dot_daena__pb2.StatusResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True,
)
@staticmethod
def GetHealth(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/daena.v1.Daena/GetHealth",
daena_dot_v1_dot_daena__pb2.Empty.SerializeToString,
daena_dot_v1_dot_daena__pb2.HealthResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True,
)
@staticmethod
def GetConfig(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/daena.v1.Daena/GetConfig",
daena_dot_v1_dot_daena__pb2.Empty.SerializeToString,
daena_dot_v1_dot_daena__pb2.ConfigResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True,
)
@staticmethod
def ListPlugins(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/daena.v1.Daena/ListPlugins",
daena_dot_v1_dot_daena__pb2.Empty.SerializeToString,
daena_dot_v1_dot_daena__pb2.PluginListResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True,
)
@staticmethod
def GetQueueState(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/daena.v1.Daena/GetQueueState",
daena_dot_v1_dot_daena__pb2.Empty.SerializeToString,
daena_dot_v1_dot_daena__pb2.QueueStateResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True,
)
@staticmethod
def ListPendingRecords(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/daena.v1.Daena/ListPendingRecords",
daena_dot_v1_dot_daena__pb2.Pagination.SerializeToString,
daena_dot_v1_dot_daena__pb2.RecordList.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True,
)
@staticmethod
def ListDeadRecords(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/daena.v1.Daena/ListDeadRecords",
daena_dot_v1_dot_daena__pb2.Pagination.SerializeToString,
daena_dot_v1_dot_daena__pb2.RecordList.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True,
)
@staticmethod
def ListSinks(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/daena.v1.Daena/ListSinks",
daena_dot_v1_dot_daena__pb2.Empty.SerializeToString,
daena_dot_v1_dot_daena__pb2.SinkList.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True,
)
@staticmethod
def ReloadConfig(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/daena.v1.Daena/ReloadConfig",
daena_dot_v1_dot_daena__pb2.Empty.SerializeToString,
daena_dot_v1_dot_daena__pb2.ReloadResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True,
)
-15
View File
@@ -1,15 +0,0 @@
from __future__ import annotations
from fastapi import APIRouter
from daena.api.routes.plugins import router as plugins_router
from daena.api.routes.queue import router as queue_router
from daena.api.routes.sinks import router as sinks_router
from daena.api.routes.system import router as system_router
api_router = APIRouter()
api_router.include_router(system_router, prefix="/system", tags=["system"])
api_router.include_router(plugins_router, prefix="/plugins", tags=["plugins"])
api_router.include_router(queue_router, prefix="/queue", tags=["queue"])
api_router.include_router(sinks_router, prefix="/sinks", tags=["sinks"])
View File
-16
View File
@@ -1,16 +0,0 @@
from __future__ import annotations
from fastapi import APIRouter, Request
router = APIRouter()
@router.get("")
async def list_plugins(request: Request) -> dict:
runtime = request.app.state.runtime
registry = runtime.registry
plugins = registry.all_plugins()
result: dict[str, list[dict]] = {}
for category, entries in plugins.items():
result[category] = [{"name": name, "class": cls.__name__} for name, cls in entries.items()]
return {"plugins": result}
-50
View File
@@ -1,50 +0,0 @@
from __future__ import annotations
from fastapi import APIRouter, Query, Request
from daena.persistence.models import RecordState
router = APIRouter()
@router.get("")
async def get_queue_state(request: Request) -> dict:
runtime = request.app.state.runtime
backend = runtime.backend
if backend is None:
return {"error": "backend not initialized"}
return {
"pending": backend.count(RecordState.pending),
"delivered": backend.count(RecordState.delivered),
"failed": backend.count(RecordState.failed),
"dead": backend.count(RecordState.dead),
"total": backend.count(),
}
@router.get("/pending")
async def get_pending(
request: Request,
limit: int = Query(default=100, le=1000),
offset: int = Query(default=0, ge=0),
) -> dict:
runtime = request.app.state.runtime
backend = runtime.backend
if backend is None:
return {"error": "backend not initialized", "records": []}
records = backend.list_by_state(RecordState.pending, limit=limit, offset=offset)
return {"records": [r.to_dict() for r in records], "count": len(records)}
@router.get("/dead")
async def get_dead(
request: Request,
limit: int = Query(default=100, le=1000),
offset: int = Query(default=0, ge=0),
) -> dict:
runtime = request.app.state.runtime
backend = runtime.backend
if backend is None:
return {"error": "backend not initialized", "records": []}
records = backend.list_by_state(RecordState.dead, limit=limit, offset=offset)
return {"records": [r.to_dict() for r in records], "count": len(records)}
-17
View File
@@ -1,17 +0,0 @@
from __future__ import annotations
from fastapi import APIRouter, Request
router = APIRouter()
@router.get("")
async def list_sinks(request: Request) -> dict:
runtime = request.app.state.runtime
pipeline = runtime.pipeline
if pipeline is None:
return {"sinks": []}
sinks = pipeline.sinks
return {
"sinks": [{"name": s.name, "type": type(s).__name__, "config": s.config} for s in sinks]
}
-50
View File
@@ -1,50 +0,0 @@
from __future__ import annotations
from fastapi import APIRouter, Request
from daena.core.runtime import DaenaRuntime
router = APIRouter()
def _resolve_runtime(request: Request) -> DaenaRuntime:
return request.app.state.runtime # type: ignore[no-any-return]
@router.get("/status")
async def get_status(request: Request) -> dict:
runtime = _resolve_runtime(request)
return {
"service": "daena",
"version": "0.1.0",
"running": runtime._running,
"uptime": None,
}
@router.get("/health")
async def get_health(request: Request) -> dict:
runtime = _resolve_runtime(request)
healthy = runtime._running
return {
"healthy": healthy,
"running": runtime._running,
"backend": runtime.backend is not None,
"plugins_loaded": bool(runtime.registry.list_sources()),
}
@router.get("/config")
async def get_config(request: Request) -> dict:
runtime = _resolve_runtime(request)
cfg = runtime.config
return {
"data_dir": cfg.data_dir,
"log_dir": cfg.log_dir,
"api": cfg.api.model_dump(),
"pipeline": cfg.pipeline.model_dump(),
"persistence": cfg.persistence.model_dump(),
"sources": [s.model_dump() for s in cfg.sources],
"processors": [p.model_dump() for p in cfg.processors],
"sinks": [s.model_dump() for s in cfg.sinks],
}
-50
View File
@@ -1,50 +0,0 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from daena.api.router import api_router
from daena.core.runtime import DaenaRuntime
from daena.logutil import get_logger
log = get_logger("daena.api")
class DaenaAPI:
"""Wraps the FastAPI application with daena runtime integration."""
def __init__(self, runtime: DaenaRuntime) -> None:
self._runtime = runtime
self._app: FastAPI | None = None
def build(self) -> FastAPI:
runtime = self._runtime
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
log.info("api_starting")
await runtime.start()
yield
log.info("api_stopping")
await runtime.stop()
app = FastAPI(
title="Daena",
description="Daena service management API",
version="0.1.0",
lifespan=lifespan,
)
app.state.runtime = runtime
app.include_router(api_router, prefix="/api/v1")
self._app = app
return app
@property
def app(self) -> FastAPI:
if self._app is None:
return self.build()
return self._app
+36 -35
View File
@@ -1,44 +1,16 @@
from __future__ import annotations
import asyncio
from pathlib import Path
import uvicorn
from daena.api.server import DaenaAPI
from daena.api.grpc_server import DaenaGRPCServer
from daena.config import load_config
from daena.core.runtime import DaenaRuntime
from daena.logutil import get_logger, setup_logging
log = get_logger("daena.app")
def serve() -> None:
"""Entry point for `daena-serve` (systemd/uvicorn mode)."""
config_path = _resolve_config_path()
config = load_config(config_path)
setup_logging(
level=config.logging.level,
fmt=config.logging.format,
log_file=config.logging.file,
)
runtime = _build_runtime(config)
api = DaenaAPI(runtime)
app = api.build()
log.info(
"starting_server",
host=config.api.http.host,
port=config.api.http.port,
)
uvicorn.run(
app,
host=config.api.http.host,
port=config.api.http.port,
workers=config.api.http.workers,
log_level=config.logging.level,
)
def _resolve_config_path() -> str | None:
for candidate in (
"/etc/daena/daena.yaml",
@@ -51,11 +23,40 @@ def _resolve_config_path() -> str | None:
return None
def _build_runtime(config):
"""Lazy import to prevent premature backend init."""
from daena.core.runtime import DaenaRuntime
def serve() -> None:
"""Entry point for `daena-serve` (systemd/gRPC mode)."""
config_path = _resolve_config_path()
config = load_config(config_path)
setup_logging(
level=config.logging.level,
fmt=config.logging.format,
log_file=config.logging.file,
)
return DaenaRuntime(config)
runtime = DaenaRuntime(config)
server = DaenaGRPCServer(
runtime,
host=config.api.grpc.host,
port=config.api.grpc.port,
)
async def run() -> None:
await runtime.start()
await server.start()
log.info(
"grpc_server_serving",
address=f"{config.api.grpc.host}:{config.api.grpc.port}",
)
try:
await server.serve_forever()
except asyncio.CancelledError:
pass
finally:
await server.stop()
await runtime.stop()
asyncio.run(run())
if __name__ == "__main__":
+65 -82
View File
@@ -1,11 +1,15 @@
from __future__ import annotations
import httpx
import json
import grpc
import typer
from rich.console import Console
from rich.table import Table
from daena._version import __version__
from daena.api.pb2 import daena_pb2 as pb2
from daena.api.pb2 import daena_pb2_grpc as pb2_grpc
app = typer.Typer(
name="daena",
@@ -13,121 +17,100 @@ app = typer.Typer(
)
console = Console()
DEFAULT_API_URL = "http://127.0.0.1:8642/api/v1"
DEFAULT_TARGET = "127.0.0.1:8642"
def _client(timeout: float = 10.0) -> httpx.Client:
return httpx.Client(timeout=httpx.Timeout(timeout))
def _stub(target: str | None = None) -> pb2_grpc.DaenaStub:
tgt = target or DEFAULT_TARGET
channel = grpc.insecure_channel(tgt)
return pb2_grpc.DaenaStub(channel)
def _api_url() -> str:
return DEFAULT_API_URL # TODO: make configurable via env/flag
def _connect(target: str | None = None) -> pb2_grpc.DaenaStub:
try:
stub = _stub(target)
stub.GetStatus(pb2.Empty(), timeout=3.0)
return stub
except grpc.RpcError:
console.print("[red]Cannot connect to daena service[/red]")
raise typer.Exit(1) from None
except Exception:
console.print("[red]Cannot connect to daena service[/red]")
raise typer.Exit(1) from None
@app.command()
def status() -> None:
"""Show daemon status."""
try:
with _client(5.0) as c:
resp = c.get(f"{_api_url()}/system/status")
resp.raise_for_status()
data = resp.json()
console.print(f"[bold]Daena[/bold] v{data.get('version', '?')}")
console.print(f"Running: {data.get('running', '?')}")
except httpx.ConnectError:
console.print("[red]Cannot connect to daena service[/red]")
raise typer.Exit(1) from None
stub = _connect()
resp = stub.GetStatus(pb2.Empty(), timeout=5.0)
console.print(f"[bold]Daena[/bold] v{resp.version}")
console.print(f"Running: {resp.running}")
@app.command()
def health() -> None:
"""Check service health."""
try:
with _client(5.0) as c:
resp = c.get(f"{_api_url()}/system/health")
resp.raise_for_status()
data = resp.json()
if data.get("healthy"):
console.print("[green]Healthy[/green]")
else:
console.print("[red]Unhealthy[/red]")
raise typer.Exit(1)
except httpx.ConnectError:
console.print("[red]Cannot connect to daena service[/red]")
raise typer.Exit(1) from None
stub = _connect()
resp = stub.GetHealth(pb2.Empty(), timeout=5.0)
if resp.healthy:
console.print("[green]Healthy[/green]")
else:
console.print("[red]Unhealthy[/red]")
raise typer.Exit(1)
@app.command()
def queue() -> None:
"""Inspect queue state."""
try:
with _client() as c:
resp = c.get(f"{_api_url()}/queue")
resp.raise_for_status()
data = resp.json()
table = Table("State", "Count")
for key in ("pending", "delivered", "failed", "dead", "total"):
table.add_row(key, str(data.get(key, 0)))
console.print(table)
except httpx.ConnectError:
console.print("[red]Cannot connect to daena service[/red]")
raise typer.Exit(1) from None
stub = _connect()
resp = stub.GetQueueState(pb2.Empty(), timeout=5.0)
table = Table("State", "Count")
for key in ("pending", "delivered", "failed", "dead", "total"):
table.add_row(key, str(getattr(resp, key, 0)))
console.print(table)
@app.command()
def sinks() -> None:
"""List configured sinks."""
try:
with _client() as c:
resp = c.get(f"{_api_url()}/sinks")
resp.raise_for_status()
data = resp.json()
sinks_list = data.get("sinks", [])
if not sinks_list:
console.print("No sinks configured")
return
table = Table("Name", "Type")
for s in sinks_list:
table.add_row(s["name"], s["type"])
console.print(table)
except httpx.ConnectError:
console.print("[red]Cannot connect to daena service[/red]")
raise typer.Exit(1) from None
stub = _connect()
resp = stub.ListSinks(pb2.Empty(), timeout=5.0)
items = resp.sinks
if not items:
console.print("No sinks configured")
return
table = Table("Name", "Type")
for s in items:
table.add_row(s.name, s.type)
console.print(table)
@app.command()
def plugins() -> None:
"""List loaded plugins."""
try:
with _client() as c:
resp = c.get(f"{_api_url()}/plugins")
resp.raise_for_status()
data = resp.json()
plugins_data = data.get("plugins", {})
for category, items in plugins_data.items():
if items:
table = Table(f"{category.title()}")
for item in items:
table.add_row(f"{item['name']} ({item['class']})")
console.print(table)
console.print()
except httpx.ConnectError:
console.print("[red]Cannot connect to daena service[/red]")
raise typer.Exit(1) from None
stub = _connect()
resp = stub.ListPlugins(pb2.Empty(), timeout=5.0)
for label, cat in (
("Sources", resp.sources),
("Processors", resp.processors),
("Sinks", resp.sinks),
):
if cat.items:
table = Table(label)
for item in cat.items:
table.add_row(f"{item.name} ({item.class_name})")
console.print(table)
console.print()
@app.command()
def config() -> None:
"""Show loaded service configuration."""
try:
with _client() as c:
resp = c.get(f"{_api_url()}/system/config")
resp.raise_for_status()
data = resp.json()
console.print(data)
except httpx.ConnectError:
console.print("[red]Cannot connect to daena service[/red]")
raise typer.Exit(1) from None
stub = _connect()
resp = stub.GetConfig(pb2.Empty(), timeout=5.0)
parsed = json.loads(resp.config_json)
console.print(parsed)
@app.command()
+3 -3
View File
@@ -14,14 +14,14 @@ class LoggingConfig(BaseSettings):
file: str | None = None
class APIHTTPConfig(BaseSettings):
class GRPCConfig(BaseSettings):
host: str = "127.0.0.1"
port: int = 8642
workers: int = 1
max_workers: int = 10
class APIConfig(BaseSettings):
http: APIHTTPConfig = APIHTTPConfig()
grpc: GRPCConfig = GRPCConfig()
class SinkRetryConfig(BaseSettings):
+1 -1
View File
@@ -9,7 +9,7 @@ def test_minimal_config_has_defaults() -> None:
config = DaenaConfig()
assert config.data_dir == "/var/lib/daena"
assert config.logging.level == "info"
assert config.api.http.port == 8642
assert config.api.grpc.port == 8642
assert config.pipeline.batch_size == 100
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
import asyncio
import grpc # noqa: I001
import pytest
from daena.api.grpc_server import DaenaGRPCServer
from daena.api.pb2 import daena_pb2 as pb2
from daena.api.pb2 import daena_pb2_grpc as pb2_grpc
from daena.config import DaenaConfig, SinkConfig, SourceConfig
from daena.core.runtime import DaenaRuntime
@pytest.fixture
async def grpc_env():
config = DaenaConfig()
config.sources = [
SourceConfig(name="dummy", type="dummy", options={"interval": 0.2, "message": "test"})
]
config.sinks = [SinkConfig(name="log", type="dummy")]
config.persistence.lmdb.path = "/tmp/daena_grpc_test"
config.api.grpc.port = 18642
runtime = DaenaRuntime(config)
await runtime.start()
server = DaenaGRPCServer(runtime, host="127.0.0.1", port=18642)
await server.start()
channel = grpc.aio.insecure_channel("127.0.0.1:18642")
stub = pb2_grpc.DaenaStub(channel)
yield runtime, stub, server
await channel.close()
await server.stop()
await runtime.stop()
@pytest.mark.asyncio
async def test_get_status(grpc_env) -> None:
_, stub, _ = grpc_env
resp = await stub.GetStatus(pb2.Empty(), timeout=5)
assert resp.service == "daena"
assert resp.version == "0.1.0"
assert resp.running is True
@pytest.mark.asyncio
async def test_get_health(grpc_env) -> None:
_, stub, _ = grpc_env
resp = await stub.GetHealth(pb2.Empty(), timeout=5)
assert resp.healthy is True
assert resp.running is True
@pytest.mark.asyncio
async def test_get_config(grpc_env) -> None:
_, stub, _ = grpc_env
resp = await stub.GetConfig(pb2.Empty(), timeout=5)
assert resp.config_json != ""
@pytest.mark.asyncio
async def test_list_plugins(grpc_env) -> None:
_, stub, _ = grpc_env
resp = await stub.ListPlugins(pb2.Empty(), timeout=5)
assert len(resp.sources.items) > 0
assert len(resp.sinks.items) > 0
@pytest.mark.asyncio
async def test_get_queue_state(grpc_env) -> None:
_, stub, _ = grpc_env
await asyncio.sleep(1)
resp = await stub.GetQueueState(pb2.Empty(), timeout=5)
assert resp.total > 0
assert resp.pending > 0
@pytest.mark.asyncio
async def test_list_pending_records(grpc_env) -> None:
runtime, stub, _ = grpc_env
await asyncio.sleep(1)
req = pb2.Pagination(limit=10, offset=0)
resp = await stub.ListPendingRecords(req, timeout=5)
assert resp.count > 0
assert len(resp.records) > 0
assert resp.records[0].record_id != ""
@pytest.mark.asyncio
async def test_list_sinks(grpc_env) -> None:
_, stub, _ = grpc_env
resp = await stub.ListSinks(pb2.Empty(), timeout=5)
assert len(resp.sinks) >= 1
assert resp.sinks[0].name == "log"
@pytest.mark.asyncio
async def test_reload_config(grpc_env) -> None:
_, stub, _ = grpc_env
resp = await stub.ReloadConfig(pb2.Empty(), timeout=10)
assert resp.success is True