mirror of
https://github.com/arcan1s/ahriman.git
synced 2025-04-23 23:07:17 +00:00
Add web status route (#13)
* add status route * typed status and get status at the start of application
This commit is contained in:
parent
a416214e5f
commit
213b2c65a0
@ -22,9 +22,8 @@ import sys
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import ahriman.application.handlers as handlers
|
||||
import ahriman.version as version
|
||||
|
||||
from ahriman import version
|
||||
from ahriman.application import handlers
|
||||
from ahriman.models.build_status import BuildStatusEnum
|
||||
from ahriman.models.sign_settings import SignSettings
|
||||
|
||||
|
@ -20,12 +20,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
|
||||
from pathlib import Path
|
||||
from types import TracebackType
|
||||
from typing import Literal, Optional, Type
|
||||
|
||||
from ahriman import version
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.exceptions import DuplicateRun, UnsafeRun
|
||||
from ahriman.core.status.client import Client
|
||||
@ -61,12 +63,13 @@ class Lock:
|
||||
default workflow is the following:
|
||||
|
||||
check user UID
|
||||
remove lock file if force flag is set
|
||||
check if there is lock file
|
||||
check web status watcher status
|
||||
create lock file
|
||||
report to web if enabled
|
||||
"""
|
||||
self.check_user()
|
||||
self.check_version()
|
||||
self.create()
|
||||
self.reporter.update_self(BuildStatusEnum.Building)
|
||||
return self
|
||||
@ -85,6 +88,15 @@ class Lock:
|
||||
self.reporter.update_self(status)
|
||||
return False
|
||||
|
||||
def check_version(self) -> None:
|
||||
"""
|
||||
check web server version
|
||||
"""
|
||||
status = self.reporter.get_internal()
|
||||
if status.version is not None and status.version != version.__version__:
|
||||
logging.getLogger("root").warning(f"status watcher version mismatch, "
|
||||
f"our {version.__version__}, their {status.version}")
|
||||
|
||||
def check_user(self) -> None:
|
||||
"""
|
||||
check if current user is actually owner of ahriman root
|
||||
|
@ -23,6 +23,7 @@ from typing import List, Optional, Tuple, Type
|
||||
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
|
||||
from ahriman.models.internal_status import InternalStatus
|
||||
from ahriman.models.package import Package
|
||||
|
||||
|
||||
@ -62,6 +63,14 @@ class Client:
|
||||
del base
|
||||
return []
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
def get_internal(self) -> InternalStatus:
|
||||
"""
|
||||
get internal service status
|
||||
:return: current internal (web) service status
|
||||
"""
|
||||
return InternalStatus()
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
def get_self(self) -> BuildStatus:
|
||||
"""
|
||||
|
@ -24,6 +24,7 @@ from typing import List, Optional, Tuple
|
||||
|
||||
from ahriman.core.status.client import Client
|
||||
from ahriman.models.build_status import BuildStatusEnum, BuildStatus
|
||||
from ahriman.models.internal_status import InternalStatus
|
||||
from ahriman.models.package import Package
|
||||
|
||||
|
||||
@ -70,6 +71,13 @@ class WebClient(Client):
|
||||
"""
|
||||
return f"http://{self.host}:{self.port}/api/v1/packages/{base}"
|
||||
|
||||
def _status_url(self) -> str:
|
||||
"""
|
||||
url generator
|
||||
:return: full url for web service for status
|
||||
"""
|
||||
return f"http://{self.host}:{self.port}/api/v1/status"
|
||||
|
||||
def add(self, package: Package, status: BuildStatusEnum) -> None:
|
||||
"""
|
||||
add new package with status
|
||||
@ -110,6 +118,23 @@ class WebClient(Client):
|
||||
self.logger.exception(f"could not get {base}")
|
||||
return []
|
||||
|
||||
def get_internal(self) -> InternalStatus:
|
||||
"""
|
||||
get internal service status
|
||||
:return: current internal (web) service status
|
||||
"""
|
||||
try:
|
||||
response = requests.get(self._status_url())
|
||||
response.raise_for_status()
|
||||
|
||||
status_json = response.json()
|
||||
return InternalStatus.from_json(status_json)
|
||||
except requests.exceptions.HTTPError as e:
|
||||
self.logger.exception(f"could not get web service status: {WebClient._exception_response_text(e)}")
|
||||
except Exception:
|
||||
self.logger.exception("could not get web service status")
|
||||
return InternalStatus()
|
||||
|
||||
def get_self(self) -> BuildStatus:
|
||||
"""
|
||||
get ahriman status itself
|
||||
|
71
src/ahriman/models/counters.py
Normal file
71
src/ahriman/models/counters.py
Normal file
@ -0,0 +1,71 @@
|
||||
#
|
||||
# Copyright (c) 2021 ahriman team.
|
||||
#
|
||||
# This file is part of ahriman
|
||||
# (see https://github.com/arcan1s/ahriman).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, fields
|
||||
from typing import Any, Dict, List, Tuple, Type
|
||||
|
||||
from ahriman.models.build_status import BuildStatus
|
||||
from ahriman.models.package import Package
|
||||
|
||||
|
||||
@dataclass
|
||||
class Counters:
|
||||
"""
|
||||
package counters
|
||||
:ivar total: total packages count
|
||||
:ivar unknown: packages in unknown status count
|
||||
:ivar pending: packages in pending status count
|
||||
:ivar building: packages in building status count
|
||||
:ivar failed: packages in failed status count
|
||||
:ivar success: packages in success status count
|
||||
"""
|
||||
total: int
|
||||
unknown: int = 0
|
||||
pending: int = 0
|
||||
building: int = 0
|
||||
failed: int = 0
|
||||
success: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_json(cls: Type[Counters], dump: Dict[str, Any]) -> Counters:
|
||||
"""
|
||||
construct counters from json dump
|
||||
:param dump: json dump body
|
||||
:return: status counters
|
||||
"""
|
||||
# filter to only known fields
|
||||
known_fields = [pair.name for pair in fields(cls)]
|
||||
dump = {key: value for key, value in dump.items() if key in known_fields}
|
||||
return cls(**dump)
|
||||
|
||||
@classmethod
|
||||
def from_packages(cls: Type[Counters], packages: List[Tuple[Package, BuildStatus]]) -> Counters:
|
||||
"""
|
||||
construct counters from packages statuses
|
||||
:param packages: list of package and their status as per watcher property
|
||||
:return: status counters
|
||||
"""
|
||||
per_status = {"total": len(packages)}
|
||||
for _, status in packages:
|
||||
key = status.status.name.lower()
|
||||
per_status.setdefault(key, 0)
|
||||
per_status[key] += 1
|
||||
return cls(**per_status)
|
60
src/ahriman/models/internal_status.py
Normal file
60
src/ahriman/models/internal_status.py
Normal file
@ -0,0 +1,60 @@
|
||||
#
|
||||
# Copyright (c) 2021 ahriman team.
|
||||
#
|
||||
# This file is part of ahriman
|
||||
# (see https://github.com/arcan1s/ahriman).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any, Dict, Optional, Type
|
||||
|
||||
from ahriman.models.counters import Counters
|
||||
|
||||
|
||||
@dataclass
|
||||
class InternalStatus:
|
||||
"""
|
||||
internal server status
|
||||
:ivar architecture: repository architecture
|
||||
:ivar packages: packages statuses counter object
|
||||
:ivar repository: repository name
|
||||
:ivar version: service version
|
||||
"""
|
||||
architecture: Optional[str] = None
|
||||
packages: Counters = field(default=Counters(total=0))
|
||||
repository: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_json(cls: Type[InternalStatus], dump: Dict[str, Any]) -> InternalStatus:
|
||||
"""
|
||||
construct internal status from json dump
|
||||
:param dump: json dump body
|
||||
:return: internal status
|
||||
"""
|
||||
counters = Counters.from_json(dump["packages"]) if "packages" in dump else Counters(total=0)
|
||||
return cls(architecture=dump.get("architecture"),
|
||||
packages=counters,
|
||||
repository=dump.get("repository"),
|
||||
version=dump.get("version"))
|
||||
|
||||
def view(self) -> Dict[str, Any]:
|
||||
"""
|
||||
generate json status view
|
||||
:return: json-friendly dictionary
|
||||
"""
|
||||
return asdict(self)
|
@ -23,6 +23,7 @@ from ahriman.web.views.ahriman import AhrimanView
|
||||
from ahriman.web.views.index import IndexView
|
||||
from ahriman.web.views.package import PackageView
|
||||
from ahriman.web.views.packages import PackagesView
|
||||
from ahriman.web.views.status import StatusView
|
||||
|
||||
|
||||
def setup_routes(application: Application) -> None:
|
||||
@ -44,6 +45,8 @@ def setup_routes(application: Application) -> None:
|
||||
GET /api/v1/package/:base get package base status
|
||||
POST /api/v1/package/:base update package base status
|
||||
|
||||
GET /api/v1/status get web service status itself
|
||||
|
||||
:param application: web application instance
|
||||
"""
|
||||
application.router.add_get("/", IndexView)
|
||||
@ -58,3 +61,5 @@ def setup_routes(application: Application) -> None:
|
||||
application.router.add_delete("/api/v1/packages/{package}", PackageView)
|
||||
application.router.add_get("/api/v1/packages/{package}", PackageView)
|
||||
application.router.add_post("/api/v1/packages/{package}", PackageView)
|
||||
|
||||
application.router.add_get("/api/v1/status", StatusView)
|
||||
|
@ -21,8 +21,7 @@ import aiohttp_jinja2
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
import ahriman.version as version
|
||||
|
||||
from ahriman import version
|
||||
from ahriman.core.util import pretty_datetime
|
||||
from ahriman.web.views.base import BaseView
|
||||
|
||||
|
45
src/ahriman/web/views/status.py
Normal file
45
src/ahriman/web/views/status.py
Normal file
@ -0,0 +1,45 @@
|
||||
#
|
||||
# Copyright (c) 2021 ahriman team.
|
||||
#
|
||||
# This file is part of ahriman
|
||||
# (see https://github.com/arcan1s/ahriman).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
from aiohttp.web import Response, json_response
|
||||
|
||||
from ahriman import version
|
||||
from ahriman.models.counters import Counters
|
||||
from ahriman.models.internal_status import InternalStatus
|
||||
from ahriman.web.views.base import BaseView
|
||||
|
||||
|
||||
class StatusView(BaseView):
|
||||
"""
|
||||
web service status web view
|
||||
"""
|
||||
|
||||
async def get(self) -> Response:
|
||||
"""
|
||||
get current service status
|
||||
:return: 200 with service status object
|
||||
"""
|
||||
counters = Counters.from_packages(self.service.packages)
|
||||
status = InternalStatus(
|
||||
architecture=self.service.architecture,
|
||||
packages=counters,
|
||||
repository=self.service.repository.name,
|
||||
version=version.__version__)
|
||||
|
||||
return json_response(status.view())
|
@ -5,9 +5,11 @@ from pathlib import Path
|
||||
from pytest_mock import MockerFixture
|
||||
from unittest import mock
|
||||
|
||||
from ahriman import version
|
||||
from ahriman.application.lock import Lock
|
||||
from ahriman.core.exceptions import DuplicateRun, UnsafeRun
|
||||
from ahriman.models.build_status import BuildStatusEnum
|
||||
from ahriman.models.internal_status import InternalStatus
|
||||
|
||||
|
||||
def test_enter(lock: Lock, mocker: MockerFixture) -> None:
|
||||
@ -15,6 +17,7 @@ def test_enter(lock: Lock, mocker: MockerFixture) -> None:
|
||||
must process with context manager
|
||||
"""
|
||||
check_user_mock = mocker.patch("ahriman.application.lock.Lock.check_user")
|
||||
check_version_mock = mocker.patch("ahriman.application.lock.Lock.check_version")
|
||||
clear_mock = mocker.patch("ahriman.application.lock.Lock.clear")
|
||||
create_mock = mocker.patch("ahriman.application.lock.Lock.create")
|
||||
update_status_mock = mocker.patch("ahriman.core.status.client.Client.update_self")
|
||||
@ -24,6 +27,7 @@ def test_enter(lock: Lock, mocker: MockerFixture) -> None:
|
||||
check_user_mock.assert_called_once()
|
||||
clear_mock.assert_called_once()
|
||||
create_mock.assert_called_once()
|
||||
check_version_mock.assert_called_once()
|
||||
update_status_mock.assert_has_calls([
|
||||
mock.call(BuildStatusEnum.Building),
|
||||
mock.call(BuildStatusEnum.Success)
|
||||
@ -48,6 +52,30 @@ def test_exit_with_exception(lock: Lock, mocker: MockerFixture) -> None:
|
||||
])
|
||||
|
||||
|
||||
def test_check_version(lock: Lock, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
must check version correctly
|
||||
"""
|
||||
mocker.patch("ahriman.core.status.client.Client.get_internal",
|
||||
return_value=InternalStatus(version=version.__version__))
|
||||
logging_mock = mocker.patch("logging.Logger.warning")
|
||||
|
||||
lock.check_version()
|
||||
logging_mock.assert_not_called()
|
||||
|
||||
|
||||
def test_check_version_mismatch(lock: Lock, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
must check version correctly
|
||||
"""
|
||||
mocker.patch("ahriman.core.status.client.Client.get_internal",
|
||||
return_value=InternalStatus(version="version"))
|
||||
logging_mock = mocker.patch("logging.Logger.warning")
|
||||
|
||||
lock.check_version()
|
||||
logging_mock.assert_called_once()
|
||||
|
||||
|
||||
def test_check_user(lock: Lock, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
must check user correctly
|
||||
|
@ -4,6 +4,7 @@ from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.status.client import Client
|
||||
from ahriman.core.status.web_client import WebClient
|
||||
from ahriman.models.build_status import BuildStatusEnum
|
||||
from ahriman.models.internal_status import InternalStatus
|
||||
from ahriman.models.package import Package
|
||||
|
||||
|
||||
@ -38,6 +39,13 @@ def test_get(client: Client, package_ahriman: Package) -> None:
|
||||
assert client.get(None) == []
|
||||
|
||||
|
||||
def test_get_internal(client: Client) -> None:
|
||||
"""
|
||||
must return dummy status for web service
|
||||
"""
|
||||
assert client.get_internal() == InternalStatus()
|
||||
|
||||
|
||||
def test_get_self(client: Client) -> None:
|
||||
"""
|
||||
must return unknown status for service
|
||||
|
@ -7,6 +7,7 @@ from requests import Response
|
||||
|
||||
from ahriman.core.status.web_client import WebClient
|
||||
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
|
||||
from ahriman.models.internal_status import InternalStatus
|
||||
from ahriman.models.package import Package
|
||||
|
||||
|
||||
@ -26,6 +27,14 @@ def test_package_url(web_client: WebClient, package_ahriman: Package) -> None:
|
||||
assert web_client._package_url(package_ahriman.base).endswith(f"/api/v1/packages/{package_ahriman.base}")
|
||||
|
||||
|
||||
def test_status_url(web_client: WebClient) -> None:
|
||||
"""
|
||||
must generate service status url correctly
|
||||
"""
|
||||
assert web_client._status_url().startswith(f"http://{web_client.host}:{web_client.port}")
|
||||
assert web_client._status_url().endswith("/api/v1/status")
|
||||
|
||||
|
||||
def test_add(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
must process package addition
|
||||
@ -103,6 +112,37 @@ def test_get_single(web_client: WebClient, package_ahriman: Package, mocker: Moc
|
||||
assert (package_ahriman, BuildStatusEnum.Unknown) in [(package, status.status) for package, status in result]
|
||||
|
||||
|
||||
def test_get_internal(web_client: WebClient, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
must return web service status
|
||||
"""
|
||||
response_obj = Response()
|
||||
response_obj._content = json.dumps(InternalStatus(architecture="x86_64").view()).encode("utf8")
|
||||
response_obj.status_code = 200
|
||||
|
||||
requests_mock = mocker.patch("requests.get", return_value=response_obj)
|
||||
|
||||
result = web_client.get_internal()
|
||||
requests_mock.assert_called_once()
|
||||
assert result.architecture == "x86_64"
|
||||
|
||||
|
||||
def test_get_internal_failed(web_client: WebClient, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
must suppress any exception happened during web service status getting
|
||||
"""
|
||||
mocker.patch("requests.get", side_effect=Exception())
|
||||
assert web_client.get_internal() == InternalStatus()
|
||||
|
||||
|
||||
def test_get_internal_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
must suppress any exception happened during web service status getting
|
||||
"""
|
||||
mocker.patch("requests.get", side_effect=requests.exceptions.HTTPError())
|
||||
assert web_client.get_internal() == InternalStatus()
|
||||
|
||||
|
||||
def test_get_self(web_client: WebClient, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
must return service status
|
||||
|
@ -2,7 +2,10 @@ import pytest
|
||||
|
||||
from unittest.mock import MagicMock, PropertyMock
|
||||
|
||||
from ahriman import version
|
||||
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
|
||||
from ahriman.models.counters import Counters
|
||||
from ahriman.models.internal_status import InternalStatus
|
||||
from ahriman.models.package import Package
|
||||
from ahriman.models.package_description import PackageDescription
|
||||
|
||||
@ -12,6 +15,24 @@ def build_status_failed() -> BuildStatus:
|
||||
return BuildStatus(BuildStatusEnum.Failed, 42)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def counters() -> Counters:
|
||||
return Counters(total=10,
|
||||
unknown=1,
|
||||
pending=2,
|
||||
building=3,
|
||||
failed=4,
|
||||
success=0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def internal_status(counters: Counters) -> InternalStatus:
|
||||
return InternalStatus(architecture="x86_64",
|
||||
packages=counters,
|
||||
version=version.__version__,
|
||||
repository="aur-clone")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def package_tpacpi_bat_git() -> Package:
|
||||
return Package(
|
||||
|
31
tests/ahriman/models/test_counters.py
Normal file
31
tests/ahriman/models/test_counters.py
Normal file
@ -0,0 +1,31 @@
|
||||
from dataclasses import asdict
|
||||
|
||||
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
|
||||
from ahriman.models.counters import Counters
|
||||
from ahriman.models.package import Package
|
||||
|
||||
|
||||
def test_counters_from_json_view(counters: Counters) -> None:
|
||||
"""
|
||||
must construct same object from json
|
||||
"""
|
||||
assert Counters.from_json(asdict(counters)) == counters
|
||||
|
||||
|
||||
def test_counters_from_packages(package_ahriman: Package, package_python_schedule: Package) -> None:
|
||||
"""
|
||||
must construct object from list of packages with their statuses
|
||||
"""
|
||||
payload = [
|
||||
(package_ahriman, BuildStatus(status=BuildStatusEnum.Success)),
|
||||
(package_python_schedule, BuildStatus(status=BuildStatusEnum.Failed)),
|
||||
]
|
||||
|
||||
counters = Counters.from_packages(payload)
|
||||
assert counters.total == 2
|
||||
assert counters.success == 1
|
||||
assert counters.failed == 1
|
||||
|
||||
json = asdict(counters)
|
||||
total = json.pop("total")
|
||||
assert total == sum(i for i in json.values())
|
8
tests/ahriman/models/test_internal_status.py
Normal file
8
tests/ahriman/models/test_internal_status.py
Normal file
@ -0,0 +1,8 @@
|
||||
from ahriman.models.internal_status import InternalStatus
|
||||
|
||||
|
||||
def test_internal_status_from_json_view(internal_status: InternalStatus) -> None:
|
||||
"""
|
||||
must construct same object from json
|
||||
"""
|
||||
assert InternalStatus.from_json(internal_status.view()) == internal_status
|
22
tests/ahriman/web/views/test_view_status.py
Normal file
22
tests/ahriman/web/views/test_view_status.py
Normal file
@ -0,0 +1,22 @@
|
||||
from pytest_aiohttp import TestClient
|
||||
|
||||
import ahriman.version as version
|
||||
|
||||
from ahriman.models.build_status import BuildStatusEnum
|
||||
from ahriman.models.package import Package
|
||||
|
||||
|
||||
async def test_get(client: TestClient, package_ahriman: Package) -> None:
|
||||
"""
|
||||
must generate web service status correctly)
|
||||
"""
|
||||
await client.post(f"/api/v1/packages/{package_ahriman.base}",
|
||||
json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()})
|
||||
|
||||
response = await client.get("/api/v1/status")
|
||||
assert response.status == 200
|
||||
|
||||
json = await response.json()
|
||||
assert json["version"] == version.__version__
|
||||
assert json["packages"]
|
||||
assert json["packages"]["total"] == 1
|
Loading…
Reference in New Issue
Block a user