Add web status route (#13)

* add status route

* typed status and get status at the start of application
This commit is contained in:
Evgenii Alekseev 2021-04-08 01:48:53 +03:00 committed by GitHub
parent 32df4fc54f
commit f634f1df58
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 389 additions and 6 deletions

View File

@ -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

View File

@ -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

View File

@ -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:
"""

View File

@ -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

View 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)

View 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)

View File

@ -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)

View File

@ -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

View 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())

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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(

View 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())

View 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

View 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