Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add workload version to juju status #18

Merged
merged 19 commits into from
Aug 21, 2023
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion metadata.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ description: |
A charm for the matrix synapse chat server.
Synapse is a drop in replacement for other chat servers like Mattermost and Slack.
This charm is useful if you want to spin up your own chat instance.
docs: ""
amandahla marked this conversation as resolved.
Show resolved Hide resolved
issues: https://github.com/canonical/synapse-operator/issues
maintainers:
- launchpad.net/~canonical-is-devops
source: https://github.com/canonical/synapse-operator
docs: "https://discourse.charmhub.io/t/synapse-documentation-overview/11358"
assumes:
- k8s-api

Expand Down
15 changes: 15 additions & 0 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from ops.main import main

import actions
import synapse
from charm_state import CharmConfigInvalidError, CharmState
from constants import SYNAPSE_CONTAINER_NAME, SYNAPSE_PORT
from database_observer import DatabaseObserver
Expand Down Expand Up @@ -79,13 +80,27 @@ def change_config(self, _: ops.HookEvent) -> None:
return
self.model.unit.status = ops.ActiveStatus()

def _set_workload_version(self) -> None:
"""Set workload version with Synapse version."""
container = self.unit.get_container(SYNAPSE_CONTAINER_NAME)
if not container.can_connect():
self.unit.status = ops.MaintenanceStatus("Waiting for pebble")
return
try:
synapse_version = synapse.get_version()
self.unit.set_workload_version(synapse_version)
except synapse.APIError as exc:
logger.debug("Cannot set workload version at this time: %s", exc)

def _on_config_changed(self, event: ops.HookEvent) -> None:
"""Handle changed configuration.

Args:
event: Event triggering after config is changed.
"""
self.change_config(event)
logger.debug("Setting workload in config-changed event")
self._set_workload_version()

def _on_pebble_ready(self, event: ops.HookEvent) -> None:
"""Handle pebble ready event.
Expand Down
1 change: 1 addition & 0 deletions src/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""This module defines constants used throughout the Synapse application."""

CHECK_READY_NAME = "synapse-ready"
CHECK_ALIVE_NAME = "synapse-alive"
COMMAND_MIGRATE_CONFIG = "migrate_config"
PROMETHEUS_TARGET_PORT = "9000"
SYNAPSE_CONFIG_DIR = "/data"
Expand Down
4 changes: 2 additions & 2 deletions src/database_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ def prepare(self) -> None:
).format(sql.Literal(self._database_name))
)
except psycopg2.Error as exc:
logger.error("Failed to prepare database: %s", str(exc))
logger.exception("Failed to prepare database: %r", exc)
raise
finally:
self._close()
Expand All @@ -111,7 +111,7 @@ def erase(self) -> None:
).format(sql.Identifier(self._database_name))
)
except psycopg2.Error as exc:
logger.error("Failed to erase database: %s", str(exc))
logger.exception("Failed to erase database: %r", exc)
raise
finally:
self._close()
2 changes: 2 additions & 0 deletions src/pebble.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import synapse
from charm_state import CharmState
from constants import (
CHECK_ALIVE_NAME,
CHECK_READY_NAME,
SYNAPSE_COMMAND_PATH,
SYNAPSE_CONTAINER_NAME,
Expand Down Expand Up @@ -115,6 +116,7 @@ def _pebble_layer(self) -> ops.pebble.LayerDict:
},
"checks": {
CHECK_READY_NAME: synapse.check_ready(),
CHECK_ALIVE_NAME: synapse.check_alive(),
},
}
return typing.cast(ops.pebble.LayerDict, layer)
Expand Down
3 changes: 2 additions & 1 deletion src/synapse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
"""Synapse package is used to interact with Synapse instance."""

# Exporting methods to be used for another modules
from .api import APIError, register_user # noqa: F401
from .api import APIError, get_version, register_user # noqa: F401
from .workload import ( # noqa: F401
ExecResult,
WorkloadError,
check_alive,
check_ready,
enable_metrics,
execute_migrate_config,
Expand Down
80 changes: 73 additions & 7 deletions src/synapse/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,22 @@
import hashlib
import hmac
import logging
import re
import typing

import requests
from requests import Session
from requests.adapters import HTTPAdapter
from urllib3.util import Retry

from user import User

logger = logging.getLogger(__name__)

SYNAPSE_URL = "http://localhost:8008"
REGISTER_URL = f"{SYNAPSE_URL}/_synapse/admin/v1/register"
VERSION_URL = f"{SYNAPSE_URL}/_synapse/admin/v1/server_version"
SYNAPSE_VERSION_REGEX = r"(\d+\.\d+\.\d+(?:\w+)?)\s?"


class APIError(Exception):
Expand All @@ -30,22 +36,30 @@ class APIError(Exception):
"""

def __init__(self, msg: str):
"""Initialize a new instance of the RegisterUserError exception.
"""Initialize a new instance of the APIError exception.

Args:
msg (str): Explanation of the error.
"""
self.msg = msg


class RegisterUserError(APIError):
"""Exception raised when registering user via API fails."""


class NetworkError(APIError):
"""Exception raised when requesting API fails due network issues."""


class GetVersionError(APIError):
"""Exception raised when getting version via API fails."""


class VersionNotFoundError(GetVersionError):
"""Exception raised when version is not found."""


class VersionUnexpectedContentError(GetVersionError):
"""Exception raised when output of getting version is unexpected."""


def register_user(registration_shared_secret: str, user: User) -> None:
"""Register user.

Expand Down Expand Up @@ -83,7 +97,7 @@ def register_user(registration_shared_secret: str, user: User) -> None:
requests.exceptions.Timeout,
requests.exceptions.HTTPError,
) as exc:
logger.error("Failed to request %s : %s", REGISTER_URL, exc)
logger.exception("Failed to request %s : %r", REGISTER_URL, exc)
raise NetworkError(f"Failed to request {REGISTER_URL}.") from exc


Expand Down Expand Up @@ -147,5 +161,57 @@ def _get_nonce() -> str:
requests.exceptions.Timeout,
requests.exceptions.HTTPError,
) as exc:
logger.error("Failed to request %s : %s", REGISTER_URL, exc)
logger.exception("Failed to request %s : %r", REGISTER_URL, exc)
raise NetworkError(f"Failed to request {REGISTER_URL}.") from exc


def get_version() -> str:
"""Get version.

Expected API output:
{
"server_version": "0.99.2rc1 (b=develop, abcdef123)",
"python_version": "3.7.8"
}

We're using retry here because after the config change, Synapse is restarted.

Returns:
The version returned by Synapse API.

Raises:
NetworkError: if there was an error fetching the version.
GetVersionError: if there was an error while reading version.
"""
try:
session = Session()
retries = Retry(
total=3,
backoff_factor=3,
)
session.mount("http://", HTTPAdapter(max_retries=retries))
res = session.get(VERSION_URL, timeout=10)
res.raise_for_status()
res_json = res.json()
server_version = res_json.get("server_version", None)
amandahla marked this conversation as resolved.
Show resolved Hide resolved
if server_version is None:
# Exception not in docstring because is captured.
amandahla marked this conversation as resolved.
Show resolved Hide resolved
raise VersionNotFoundError( # noqa: DCO053
f"There is no server_version in JSON output: {res_json}"
)
version_match = re.search(SYNAPSE_VERSION_REGEX, server_version)
if not version_match:
# Exception not in docstring because is captured.
amandahla marked this conversation as resolved.
Show resolved Hide resolved
raise VersionUnexpectedContentError( # noqa: DCO053
f"server_version has unexpected content: {server_version}"
)
return version_match.group(1)
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as exc:
logger.exception("Failed to connect to %s: %r", VERSION_URL, exc)
raise NetworkError(f"Failed to connect to {VERSION_URL}.") from exc
except requests.exceptions.HTTPError as exc:
logger.exception("HTTP error from %s: %r", VERSION_URL, exc)
raise NetworkError(f"HTTP error from {VERSION_URL}.") from exc
except GetVersionError as exc:
logger.exception("Failed to get version: %r", exc)
raise GetVersionError(str(exc)) from exc
26 changes: 21 additions & 5 deletions src/synapse/workload.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
SYNAPSE_PORT,
)

from .api import VERSION_URL

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -69,14 +71,28 @@ class ExecResult(typing.NamedTuple):


def check_ready() -> typing.Dict:
"""Return the Synapse container check.
"""Return the Synapse container ready check.

Returns:
Dict: check object converted to its dict representation.
"""
check = Check(CHECK_READY_NAME)
check.override = "replace"
check.level = "ready"
check.http = {"url": VERSION_URL}
# _CheckDict cannot be imported
return check.to_dict() # type: ignore
amandahla marked this conversation as resolved.
Show resolved Hide resolved


def check_alive() -> typing.Dict:
"""Return the Synapse container alive check.

Returns:
Dict: check object converted to its dict representation.
"""
check = Check(CHECK_READY_NAME)
check.override = "replace"
check.level = "alive"
check.tcp = {"port": SYNAPSE_PORT}
# _CheckDict cannot be imported
return check.to_dict() # type: ignore
Expand Down Expand Up @@ -167,8 +183,8 @@ def reset_instance(container: ops.Container) -> None:
if "device or resource busy" in str(path_error):
pass
else:
logger.error(
"exception while erasing directory %s: %s", SYNAPSE_CONFIG_DIR, path_error
logger.exception(
"exception while erasing directory %s: %r", SYNAPSE_CONFIG_DIR, path_error
)
raise

Expand Down Expand Up @@ -275,8 +291,8 @@ def _get_configuration_field(container: ops.Container, fieldname: str) -> typing
SYNAPSE_CONFIG_PATH,
)
return None
logger.error(
"exception while reading configuration file %s: %s",
logger.exception(
"exception while reading configuration file %s: %r",
SYNAPSE_CONFIG_PATH,
path_error,
)
Expand Down
27 changes: 27 additions & 0 deletions tests/integration/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
# See LICENSE file for licensing details.

"""Integration tests for Synapse charm."""
import json
import logging
import re
import typing

import pytest
Expand All @@ -15,6 +17,7 @@
from pytest_operator.plugin import OpsTest

from constants import SYNAPSE_PORT
from synapse.api import SYNAPSE_VERSION_REGEX

# caused by pytest fixtures
# pylint: disable=too-many-arguments
Expand Down Expand Up @@ -230,3 +233,27 @@ async def test_register_user_action(
)
assert response.status_code == 200
assert response.json()["access_token"]


async def test_workload_version(
ops_test: OpsTest,
synapse_app: Application,
get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]],
) -> None:
"""
arrange: a deployed Synapse charm.
act: get status from Juju.
assert: the workload version is set and match the one given by Synapse API request.
"""
_, status, _ = await ops_test.juju("status", "--format", "json")
status = json.loads(status)
juju_workload_version = status["applications"][synapse_app.name].get("version", "")
assert juju_workload_version
for unit_ip in await get_unit_ips(synapse_app.name):
res = requests.get(
f"http://{unit_ip}:{SYNAPSE_PORT}/_synapse/admin/v1/server_version", timeout=5
)
server_version = res.json()["server_version"]
version_match = re.search(SYNAPSE_VERSION_REGEX, server_version)
assert version_match
assert version_match.group(1) == juju_workload_version
3 changes: 2 additions & 1 deletion tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,9 @@ def start_cmd_handler(argv: list[str]) -> synapse.ExecResult:


@pytest.fixture(name="harness_server_name_configured")
def harness_server_name_configured_fixture(harness: Harness) -> Harness:
def harness_server_name_configured_fixture(harness: Harness, monkeypatch) -> Harness:
"""Ops testing framework harness fixture with server_name already configured."""
monkeypatch.setattr(synapse, "get_version", lambda *_args, **_kwargs: "")
harness.disable_hooks()
harness.update_config({"server_name": TEST_SERVER_NAME})
harness.enable_hooks()
Expand Down
8 changes: 4 additions & 4 deletions tests/unit/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

def test_synapse_pebble_layer(harness_server_name_configured: Harness) -> None:
"""
arrange: none
arrange: charm deployed.
act: start the Synapse charm, set Synapse container to be ready and set server_name.
assert: Synapse charm should submit the correct Synapse pebble layer to pebble.
"""
Expand Down Expand Up @@ -53,7 +53,7 @@ def test_synapse_pebble_layer(harness_server_name_configured: Harness) -> None:
)
def test_synapse_migrate_config_error(harness_server_name_configured: Harness) -> None:
"""
arrange: none
arrange: charm deployed.
act: start the Synapse charm, set Synapse container to be ready and set server_name.
assert: Synapse charm should be blocked by error on migrate_config command.
"""
Expand All @@ -64,7 +64,7 @@ def test_synapse_migrate_config_error(harness_server_name_configured: Harness) -

def test_container_down(harness_server_name_configured: Harness) -> None:
"""
arrange: none
arrange: charm deployed.
act: start the Synapse charm, set server_name, set Synapse container to be down
and then try to change report_stats.
assert: Synapse charm should submit the correct status.
Expand All @@ -78,7 +78,7 @@ def test_container_down(harness_server_name_configured: Harness) -> None:

def test_server_name_empty(harness: Harness) -> None:
"""
arrange: none
arrange: charm deployed.
act: start the Synapse charm and set Synapse container to be ready.
assert: Synapse charm waits for server_name to be set.
"""
Expand Down
Loading