mirror of
https://git.selfprivacy.org/SelfPrivacy/selfprivacy-rest-api.git
synced 2024-11-09 20:53:10 +00:00
Merge pull request 'feat: graphql endpoint to fetch system logs' (#116) from nhnn/selfprivacy-rest-api:api-logs into master
Reviewed-on: https://git.selfprivacy.org/SelfPrivacy/selfprivacy-rest-api/pulls/116 Reviewed-by: Inex Code <inex.code@selfprivacy.org>
This commit is contained in:
commit
16d1f9f21a
|
@ -14,6 +14,7 @@ pythonPackages.buildPythonPackage rec {
|
||||||
pydantic
|
pydantic
|
||||||
pytz
|
pytz
|
||||||
redis
|
redis
|
||||||
|
systemd
|
||||||
setuptools
|
setuptools
|
||||||
strawberry-graphql
|
strawberry-graphql
|
||||||
typing-extensions
|
typing-extensions
|
||||||
|
|
|
@ -2,11 +2,11 @@
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1709677081,
|
"lastModified": 1719957072,
|
||||||
"narHash": "sha256-tix36Y7u0rkn6mTm0lA45b45oab2cFLqAzDbJxeXS+c=",
|
"narHash": "sha256-gvFhEf5nszouwLAkT9nWsDzocUTqLWHuL++dvNjMp9I=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "880992dcc006a5e00dd0591446fdf723e6a51a64",
|
"rev": "7144d6241f02d171d25fba3edeaf15e0f2592105",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
88
selfprivacy_api/graphql/queries/logs.py
Normal file
88
selfprivacy_api/graphql/queries/logs.py
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
"""System logs"""
|
||||||
|
from datetime import datetime
|
||||||
|
import typing
|
||||||
|
import strawberry
|
||||||
|
from selfprivacy_api.utils.systemd_journal import get_paginated_logs
|
||||||
|
|
||||||
|
|
||||||
|
@strawberry.type
|
||||||
|
class LogEntry:
|
||||||
|
message: str = strawberry.field()
|
||||||
|
timestamp: datetime = strawberry.field()
|
||||||
|
priority: typing.Optional[int] = strawberry.field()
|
||||||
|
systemd_unit: typing.Optional[str] = strawberry.field()
|
||||||
|
systemd_slice: typing.Optional[str] = strawberry.field()
|
||||||
|
|
||||||
|
def __init__(self, journal_entry: typing.Dict):
|
||||||
|
self.entry = journal_entry
|
||||||
|
self.message = journal_entry["MESSAGE"]
|
||||||
|
self.timestamp = journal_entry["__REALTIME_TIMESTAMP"]
|
||||||
|
self.priority = journal_entry.get("PRIORITY")
|
||||||
|
self.systemd_unit = journal_entry.get("_SYSTEMD_UNIT")
|
||||||
|
self.systemd_slice = journal_entry.get("_SYSTEMD_SLICE")
|
||||||
|
|
||||||
|
@strawberry.field()
|
||||||
|
def cursor(self) -> str:
|
||||||
|
return self.entry["__CURSOR"]
|
||||||
|
|
||||||
|
|
||||||
|
@strawberry.type
|
||||||
|
class LogsPageMeta:
|
||||||
|
up_cursor: typing.Optional[str] = strawberry.field()
|
||||||
|
down_cursor: typing.Optional[str] = strawberry.field()
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, up_cursor: typing.Optional[str], down_cursor: typing.Optional[str]
|
||||||
|
):
|
||||||
|
self.up_cursor = up_cursor
|
||||||
|
self.down_cursor = down_cursor
|
||||||
|
|
||||||
|
|
||||||
|
@strawberry.type
|
||||||
|
class PaginatedEntries:
|
||||||
|
page_meta: LogsPageMeta = strawberry.field(
|
||||||
|
description="Metadata to aid in pagination."
|
||||||
|
)
|
||||||
|
entries: typing.List[LogEntry] = strawberry.field(
|
||||||
|
description="The list of log entries."
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, meta: LogsPageMeta, entries: typing.List[LogEntry]):
|
||||||
|
self.page_meta = meta
|
||||||
|
self.entries = entries
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_entries(entries: typing.List[LogEntry]):
|
||||||
|
if entries == []:
|
||||||
|
return PaginatedEntries(LogsPageMeta(None, None), [])
|
||||||
|
|
||||||
|
return PaginatedEntries(
|
||||||
|
LogsPageMeta(
|
||||||
|
entries[0].cursor(),
|
||||||
|
entries[-1].cursor(),
|
||||||
|
),
|
||||||
|
entries,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@strawberry.type
|
||||||
|
class Logs:
|
||||||
|
@strawberry.field()
|
||||||
|
def paginated(
|
||||||
|
self,
|
||||||
|
limit: int = 20,
|
||||||
|
# All entries returned will be lesser than this cursor. Sets upper bound on results.
|
||||||
|
up_cursor: str | None = None,
|
||||||
|
# All entries returned will be greater than this cursor. Sets lower bound on results.
|
||||||
|
down_cursor: str | None = None,
|
||||||
|
) -> PaginatedEntries:
|
||||||
|
if limit > 50:
|
||||||
|
raise Exception("You can't fetch more than 50 entries via single request.")
|
||||||
|
return PaginatedEntries.from_entries(
|
||||||
|
list(
|
||||||
|
map(
|
||||||
|
lambda x: LogEntry(x),
|
||||||
|
get_paginated_logs(limit, up_cursor, down_cursor),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
|
@ -4,6 +4,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import AsyncGenerator, List
|
from typing import AsyncGenerator, List
|
||||||
import strawberry
|
import strawberry
|
||||||
|
from strawberry.types import Info
|
||||||
|
|
||||||
from selfprivacy_api.graphql import IsAuthenticated
|
from selfprivacy_api.graphql import IsAuthenticated
|
||||||
from selfprivacy_api.graphql.mutations.deprecated_mutations import (
|
from selfprivacy_api.graphql.mutations.deprecated_mutations import (
|
||||||
|
@ -25,6 +26,7 @@ from selfprivacy_api.graphql.mutations.backup_mutations import BackupMutations
|
||||||
from selfprivacy_api.graphql.queries.api_queries import Api
|
from selfprivacy_api.graphql.queries.api_queries import Api
|
||||||
from selfprivacy_api.graphql.queries.backup import Backup
|
from selfprivacy_api.graphql.queries.backup import Backup
|
||||||
from selfprivacy_api.graphql.queries.jobs import Job
|
from selfprivacy_api.graphql.queries.jobs import Job
|
||||||
|
from selfprivacy_api.graphql.queries.logs import LogEntry, Logs
|
||||||
from selfprivacy_api.graphql.queries.services import Services
|
from selfprivacy_api.graphql.queries.services import Services
|
||||||
from selfprivacy_api.graphql.queries.storage import Storage
|
from selfprivacy_api.graphql.queries.storage import Storage
|
||||||
from selfprivacy_api.graphql.queries.system import System
|
from selfprivacy_api.graphql.queries.system import System
|
||||||
|
@ -33,6 +35,7 @@ from selfprivacy_api.graphql.subscriptions.jobs import ApiJob
|
||||||
from selfprivacy_api.graphql.subscriptions.jobs import (
|
from selfprivacy_api.graphql.subscriptions.jobs import (
|
||||||
job_updates as job_update_generator,
|
job_updates as job_update_generator,
|
||||||
)
|
)
|
||||||
|
from selfprivacy_api.graphql.subscriptions.logs import log_stream
|
||||||
|
|
||||||
from selfprivacy_api.graphql.mutations.users_mutations import UsersMutations
|
from selfprivacy_api.graphql.mutations.users_mutations import UsersMutations
|
||||||
from selfprivacy_api.graphql.queries.users import Users
|
from selfprivacy_api.graphql.queries.users import Users
|
||||||
|
@ -53,6 +56,11 @@ class Query:
|
||||||
"""System queries"""
|
"""System queries"""
|
||||||
return System()
|
return System()
|
||||||
|
|
||||||
|
@strawberry.field(permission_classes=[IsAuthenticated])
|
||||||
|
def logs(self) -> Logs:
|
||||||
|
"""Log queries"""
|
||||||
|
return Logs()
|
||||||
|
|
||||||
@strawberry.field(permission_classes=[IsAuthenticated])
|
@strawberry.field(permission_classes=[IsAuthenticated])
|
||||||
def users(self) -> Users:
|
def users(self) -> Users:
|
||||||
"""Users queries"""
|
"""Users queries"""
|
||||||
|
@ -137,11 +145,11 @@ class Mutation(
|
||||||
|
|
||||||
|
|
||||||
# A cruft for Websockets
|
# A cruft for Websockets
|
||||||
def authenticated(info: strawberry.types.Info) -> bool:
|
def authenticated(info: Info) -> bool:
|
||||||
return IsAuthenticated().has_permission(source=None, info=info)
|
return IsAuthenticated().has_permission(source=None, info=info)
|
||||||
|
|
||||||
|
|
||||||
def reject_if_unauthenticated(info: strawberry.types.Info):
|
def reject_if_unauthenticated(info: Info):
|
||||||
if not authenticated(info):
|
if not authenticated(info):
|
||||||
raise Exception(IsAuthenticated().message)
|
raise Exception(IsAuthenticated().message)
|
||||||
|
|
||||||
|
@ -154,20 +162,23 @@ class Subscription:
|
||||||
demands it while the spec is vague in this area."""
|
demands it while the spec is vague in this area."""
|
||||||
|
|
||||||
@strawberry.subscription
|
@strawberry.subscription
|
||||||
async def job_updates(
|
async def job_updates(self, info: Info) -> AsyncGenerator[List[ApiJob], None]:
|
||||||
self, info: strawberry.types.Info
|
|
||||||
) -> AsyncGenerator[List[ApiJob], None]:
|
|
||||||
reject_if_unauthenticated(info)
|
reject_if_unauthenticated(info)
|
||||||
return job_update_generator()
|
return job_update_generator()
|
||||||
|
|
||||||
@strawberry.subscription
|
@strawberry.subscription
|
||||||
# Used for testing, consider deletion to shrink attack surface
|
# Used for testing, consider deletion to shrink attack surface
|
||||||
async def count(self, info: strawberry.types.Info) -> AsyncGenerator[int, None]:
|
async def count(self, info: Info) -> AsyncGenerator[int, None]:
|
||||||
reject_if_unauthenticated(info)
|
reject_if_unauthenticated(info)
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
yield i
|
yield i
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
@strawberry.subscription
|
||||||
|
async def log_entries(self, info: Info) -> AsyncGenerator[LogEntry, None]:
|
||||||
|
reject_if_unauthenticated(info)
|
||||||
|
return log_stream()
|
||||||
|
|
||||||
|
|
||||||
schema = strawberry.Schema(
|
schema = strawberry.Schema(
|
||||||
query=Query,
|
query=Query,
|
||||||
|
|
31
selfprivacy_api/graphql/subscriptions/logs.py
Normal file
31
selfprivacy_api/graphql/subscriptions/logs.py
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
from systemd import journal
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from selfprivacy_api.graphql.queries.logs import LogEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def log_stream() -> AsyncGenerator[LogEntry, None]:
|
||||||
|
j = journal.Reader()
|
||||||
|
|
||||||
|
j.seek_tail()
|
||||||
|
j.get_previous()
|
||||||
|
|
||||||
|
queue = asyncio.Queue()
|
||||||
|
|
||||||
|
async def callback():
|
||||||
|
if j.process() != journal.APPEND:
|
||||||
|
return
|
||||||
|
for entry in j:
|
||||||
|
await queue.put(entry)
|
||||||
|
|
||||||
|
asyncio.get_event_loop().add_reader(j, lambda: asyncio.ensure_future(callback()))
|
||||||
|
|
||||||
|
while True:
|
||||||
|
entry = await queue.get()
|
||||||
|
try:
|
||||||
|
yield LogEntry(entry)
|
||||||
|
except Exception:
|
||||||
|
asyncio.get_event_loop().remove_reader(j)
|
||||||
|
return
|
||||||
|
queue.task_done()
|
55
selfprivacy_api/utils/systemd_journal.py
Normal file
55
selfprivacy_api/utils/systemd_journal.py
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import typing
|
||||||
|
from systemd import journal
|
||||||
|
|
||||||
|
|
||||||
|
def get_events_from_journal(
|
||||||
|
j: journal.Reader, limit: int, next: typing.Callable[[journal.Reader], typing.Dict]
|
||||||
|
):
|
||||||
|
events = []
|
||||||
|
i = 0
|
||||||
|
while i < limit:
|
||||||
|
entry = next(j)
|
||||||
|
if entry is None or entry == dict():
|
||||||
|
break
|
||||||
|
if entry["MESSAGE"] != "":
|
||||||
|
events.append(entry)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def get_paginated_logs(
|
||||||
|
limit: int = 20,
|
||||||
|
# All entries returned will be lesser than this cursor. Sets upper bound on results.
|
||||||
|
up_cursor: str | None = None,
|
||||||
|
# All entries returned will be greater than this cursor. Sets lower bound on results.
|
||||||
|
down_cursor: str | None = None,
|
||||||
|
):
|
||||||
|
j = journal.Reader()
|
||||||
|
|
||||||
|
if up_cursor is None and down_cursor is None:
|
||||||
|
j.seek_tail()
|
||||||
|
|
||||||
|
events = get_events_from_journal(j, limit, lambda j: j.get_previous())
|
||||||
|
events.reverse()
|
||||||
|
|
||||||
|
return events
|
||||||
|
elif up_cursor is None and down_cursor is not None:
|
||||||
|
j.seek_cursor(down_cursor)
|
||||||
|
j.get_previous() # pagination is exclusive
|
||||||
|
|
||||||
|
events = get_events_from_journal(j, limit, lambda j: j.get_previous())
|
||||||
|
events.reverse()
|
||||||
|
|
||||||
|
return events
|
||||||
|
elif up_cursor is not None and down_cursor is None:
|
||||||
|
j.seek_cursor(up_cursor)
|
||||||
|
j.get_next() # pagination is exclusive
|
||||||
|
|
||||||
|
events = get_events_from_journal(j, limit, lambda j: j.get_next())
|
||||||
|
|
||||||
|
return events
|
||||||
|
else:
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Pagination by both up_cursor and down_cursor is not implemented"
|
||||||
|
)
|
|
@ -81,6 +81,10 @@ def generate_service_query(query_array):
|
||||||
return "query TestService {\n services {" + "\n".join(query_array) + "}\n}"
|
return "query TestService {\n services {" + "\n".join(query_array) + "}\n}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_logs_query(query_array):
|
||||||
|
return "query TestService {\n logs {" + "\n".join(query_array) + "}\n}"
|
||||||
|
|
||||||
|
|
||||||
def mnemonic_to_hex(mnemonic):
|
def mnemonic_to_hex(mnemonic):
|
||||||
return Mnemonic(language="english").to_entropy(mnemonic).hex()
|
return Mnemonic(language="english").to_entropy(mnemonic).hex()
|
||||||
|
|
||||||
|
|
172
tests/test_graphql/test_api_logs.py
Normal file
172
tests/test_graphql/test_api_logs.py
Normal file
|
@ -0,0 +1,172 @@
|
||||||
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
from systemd import journal
|
||||||
|
|
||||||
|
from tests.test_graphql.test_websocket import init_graphql
|
||||||
|
|
||||||
|
|
||||||
|
def assert_log_entry_equals_to_journal_entry(api_entry, journal_entry):
|
||||||
|
assert api_entry["message"] == journal_entry["MESSAGE"]
|
||||||
|
assert (
|
||||||
|
datetime.fromisoformat(api_entry["timestamp"])
|
||||||
|
== journal_entry["__REALTIME_TIMESTAMP"]
|
||||||
|
)
|
||||||
|
assert api_entry.get("priority") == journal_entry.get("PRIORITY")
|
||||||
|
assert api_entry.get("systemdUnit") == journal_entry.get("_SYSTEMD_UNIT")
|
||||||
|
assert api_entry.get("systemdSlice") == journal_entry.get("_SYSTEMD_SLICE")
|
||||||
|
|
||||||
|
|
||||||
|
def take_from_journal(j, limit, next):
|
||||||
|
entries = []
|
||||||
|
for _ in range(0, limit):
|
||||||
|
entry = next(j)
|
||||||
|
if entry["MESSAGE"] != "":
|
||||||
|
entries.append(entry)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
API_GET_LOGS_WITH_UP_BORDER = """
|
||||||
|
query TestQuery($upCursor: String) {
|
||||||
|
logs {
|
||||||
|
paginated(limit: 4, upCursor: $upCursor) {
|
||||||
|
pageMeta {
|
||||||
|
upCursor
|
||||||
|
downCursor
|
||||||
|
}
|
||||||
|
entries {
|
||||||
|
message
|
||||||
|
timestamp
|
||||||
|
priority
|
||||||
|
systemdUnit
|
||||||
|
systemdSlice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
API_GET_LOGS_WITH_DOWN_BORDER = """
|
||||||
|
query TestQuery($downCursor: String) {
|
||||||
|
logs {
|
||||||
|
paginated(limit: 4, downCursor: $downCursor) {
|
||||||
|
pageMeta {
|
||||||
|
upCursor
|
||||||
|
downCursor
|
||||||
|
}
|
||||||
|
entries {
|
||||||
|
message
|
||||||
|
timestamp
|
||||||
|
priority
|
||||||
|
systemdUnit
|
||||||
|
systemdSlice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_graphql_get_logs_with_up_border(authorized_client):
|
||||||
|
j = journal.Reader()
|
||||||
|
j.seek_tail()
|
||||||
|
|
||||||
|
# < - cursor
|
||||||
|
# <- - log entry will be returned by API call.
|
||||||
|
# ...
|
||||||
|
# log <
|
||||||
|
# log <-
|
||||||
|
# log <-
|
||||||
|
# log <-
|
||||||
|
# log <-
|
||||||
|
# log
|
||||||
|
|
||||||
|
expected_entries = take_from_journal(j, 6, lambda j: j.get_previous())
|
||||||
|
expected_entries.reverse()
|
||||||
|
|
||||||
|
response = authorized_client.post(
|
||||||
|
"/graphql",
|
||||||
|
json={
|
||||||
|
"query": API_GET_LOGS_WITH_UP_BORDER,
|
||||||
|
"variables": {"upCursor": expected_entries[0]["__CURSOR"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
expected_entries = expected_entries[1:-1]
|
||||||
|
returned_entries = response.json()["data"]["logs"]["paginated"]["entries"]
|
||||||
|
|
||||||
|
assert len(returned_entries) == len(expected_entries)
|
||||||
|
|
||||||
|
for api_entry, journal_entry in zip(returned_entries, expected_entries):
|
||||||
|
assert_log_entry_equals_to_journal_entry(api_entry, journal_entry)
|
||||||
|
|
||||||
|
|
||||||
|
def test_graphql_get_logs_with_down_border(authorized_client):
|
||||||
|
j = journal.Reader()
|
||||||
|
j.seek_head()
|
||||||
|
j.get_next()
|
||||||
|
|
||||||
|
# < - cursor
|
||||||
|
# <- - log entry will be returned by API call.
|
||||||
|
# log
|
||||||
|
# log <-
|
||||||
|
# log <-
|
||||||
|
# log <-
|
||||||
|
# log <-
|
||||||
|
# log <
|
||||||
|
# ...
|
||||||
|
|
||||||
|
expected_entries = take_from_journal(j, 5, lambda j: j.get_next())
|
||||||
|
|
||||||
|
response = authorized_client.post(
|
||||||
|
"/graphql",
|
||||||
|
json={
|
||||||
|
"query": API_GET_LOGS_WITH_DOWN_BORDER,
|
||||||
|
"variables": {"downCursor": expected_entries[-1]["__CURSOR"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
expected_entries = expected_entries[:-1]
|
||||||
|
returned_entries = response.json()["data"]["logs"]["paginated"]["entries"]
|
||||||
|
|
||||||
|
assert len(returned_entries) == len(expected_entries)
|
||||||
|
|
||||||
|
for api_entry, journal_entry in zip(returned_entries, expected_entries):
|
||||||
|
assert_log_entry_equals_to_journal_entry(api_entry, journal_entry)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_websocket_subscription_for_logs(authorized_client):
|
||||||
|
with authorized_client.websocket_connect(
|
||||||
|
"/graphql", subprotocols=["graphql-transport-ws"]
|
||||||
|
) as websocket:
|
||||||
|
init_graphql(websocket)
|
||||||
|
websocket.send_json(
|
||||||
|
{
|
||||||
|
"id": "3aaa2445",
|
||||||
|
"type": "subscribe",
|
||||||
|
"payload": {
|
||||||
|
"query": "subscription TestSubscription { logEntries { message } }",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
def read_until(message, limit=5):
|
||||||
|
i = 0
|
||||||
|
while i < limit:
|
||||||
|
msg = websocket.receive_json()["payload"]["data"]["logEntries"][
|
||||||
|
"message"
|
||||||
|
]
|
||||||
|
if msg == message:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
raise Exception("Failed to read websocket data, timeout")
|
||||||
|
|
||||||
|
for i in range(0, 10):
|
||||||
|
journal.send(f"Lorem ipsum number {i}")
|
||||||
|
read_until(f"Lorem ipsum number {i}")
|
Loading…
Reference in a new issue