diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index dfe55d16..0073eebc 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -78,6 +78,9 @@ jobs: - "3.4/stable" - "3.5/stable" - "3.6/stable" + new_wait_for_idle: + - "True" + - "False" steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -116,7 +119,9 @@ jobs: # # set model defaults # juju model-defaults apt-http-proxy=$PROXY apt-https-proxy=$PROXY juju-http-proxy=$PROXY juju-https-proxy=$PROXY snap-http-proxy=$PROXY snap-https-proxy=$PROXY # juju model-defaults - - run: uvx -p ${{ matrix.python }} tox -e integration + - run: uvx -p ${{ matrix.python }} tox -s -e integration + env: + JUJU_NEW_WAIT_FOR_IDLE: ${{ matrix.new_wait_for_idle }} integration-quarantine: name: Quarantined Integration Tests @@ -144,4 +149,4 @@ jobs: with: provider: lxd juju-channel: ${{ matrix.juju }} - - run: uvx -p ${{ matrix.python }} tox -e integration-quarantine + - run: uvx -p ${{ matrix.python }} tox -s -e integration-quarantine diff --git a/docs/changelog.rst b/docs/changelog.rst index adaa842d..8d5daec6 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,18 @@ Changelog --------- +3.6.1.0 +^^^^^^^ + +Friday 20th Dec 2024 + +## What's Changed +* add 3.5.5 schema and update SCHEMAS.md by @james-garner-canonical in https://github.com/juju/python-libjuju/pull/1223 +* feat: larger default websockets frame size by @dimaqq in https://github.com/juju/python-libjuju/pull/1239 +* deprecate juju.jasyncio by @EdmilsonRodrigues in https://github.com/juju/python-libjuju/pull/1221 +* remove juju.loop, deprecated before 3.0 by @dimaqq in https://github.com/juju/python-libjuju/pull/1242 +* new wait for idle implementation, behind a feature flag ``JUJU_NEW_WAIT_FOR_IDLE`` in https://github.com/juju/python-libjuju/pull/1245 + 3.6.0.0 ^^^^^^^ diff --git a/docs/readme.rst b/docs/readme.rst index b237e206..3c656779 100644 --- a/docs/readme.rst +++ b/docs/readme.rst @@ -7,6 +7,9 @@ Bug reports: https://github.com/juju/python-libjuju/issues Documentation: https://pythonlibjuju.readthedocs.io/en/latest/ +Supported Python versions: 3.8 through 3.13 +Supported Juju versions: 3.1 through 3.6 + Design Notes ------------ diff --git a/juju/client/facade.py b/juju/client/facade.py index f0ee7513..6fd34934 100644 --- a/juju/client/facade.py +++ b/juju/client/facade.py @@ -14,7 +14,7 @@ from collections import defaultdict from glob import glob from pathlib import Path -from typing import Any, Mapping, Sequence +from typing import Any, Mapping, Sequence, TypeVar, overload import packaging.version import typing_inspect @@ -183,7 +183,7 @@ def ref_type(self, obj): return self.get_ref_type(obj["$ref"]) -CLASSES = {} +CLASSES: dict[str, type[Type]] = {} factories = codegen.Capture() @@ -479,37 +479,52 @@ def ReturnMapping(cls): # noqa: N802 def decorator(f): @functools.wraps(f) async def wrapper(*args, **kwargs): - nonlocal cls reply = await f(*args, **kwargs) - if cls is None: - return reply - if "error" in reply: - cls = CLASSES["Error"] - if typing_inspect.is_generic_type(cls) and issubclass( - typing_inspect.get_origin(cls), Sequence - ): - parameters = typing_inspect.get_parameters(cls) - result = [] - item_cls = parameters[0] - for item in reply: - result.append(item_cls.from_json(item)) - """ - if 'error' in item: - cls = CLASSES['Error'] - else: - cls = item_cls - result.append(cls.from_json(item)) - """ - else: - result = cls.from_json(reply["response"]) - - return result + return _convert_response(reply, cls=cls) return wrapper return decorator +@overload +def _convert_response(response: dict[str, Any], *, cls: type[SomeType]) -> SomeType: ... + + +@overload +def _convert_response(response: dict[str, Any], *, cls: None) -> dict[str, Any]: ... + + +def _convert_response(response: dict[str, Any], *, cls: type[Type] | None) -> Any: + if cls is None: + return response + if "error" in response: + # TODO: I don't think this ever happens, + # errors are handled by Connection.rpc(), + # though, admittedly the shape is different. + cls = CLASSES["Error"] + if typing_inspect.is_generic_type(cls) and issubclass( + typing_inspect.get_origin(cls), Sequence + ): + # TODO: I'm not sure this ever happens either. + parameters = typing_inspect.get_parameters(cls) + result = [] + item_cls = parameters[0] + for item in response: + result.append(item_cls.from_json(item)) + """ + if 'error' in item: + cls = CLASSES['Error'] + else: + cls = item_cls + result.append(cls.from_json(item)) + """ + else: + result = cls.from_json(response["response"]) + + return result + + def make_func(cls, name, description, params, result, _async=True): indent = " " args = Args(cls.schema, params) @@ -663,7 +678,7 @@ async def rpc(self, msg: dict[str, _RichJson]) -> _Json: return result @classmethod - def from_json(cls, data): + def from_json(cls, data: Type | str | dict[str, Any] | list[Any]) -> Type | None: def _parse_nested_list_entry(expr, result_dict): if isinstance(expr, str): if ">" in expr or ">=" in expr: @@ -742,6 +757,9 @@ def get(self, key, default=None): return getattr(self, attr, default) +SomeType = TypeVar("SomeType", bound=Type) + + class Schema(dict): def __init__(self, schema): self.name = schema["Name"] diff --git a/juju/model.py b/juju/model/__init__.py similarity index 94% rename from juju/model.py rename to juju/model/__init__.py index 31e93913..826cff88 100644 --- a/juju/model.py +++ b/juju/model/__init__.py @@ -1,10 +1,12 @@ # Copyright 2023 Canonical Ltd. # Licensed under the Apache V2, see LICENCE file for details. +"""Represent Juju Model, as in the workspace into which applications are deployed.""" + from __future__ import annotations import asyncio import base64 -import collections +import collections.abc import hashlib import json import logging @@ -13,6 +15,7 @@ import stat import sys import tempfile +import time import warnings import weakref import zipfile @@ -20,23 +23,26 @@ from datetime import datetime, timedelta from functools import partial from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, Mapping, overload +from typing import TYPE_CHECKING, Any, Iterable, Literal, Mapping, overload import websockets import yaml from typing_extensions import deprecated -from . import provisioner, tag, utils -from .annotationhelper import _get_annotations, _set_annotations -from .bundle import BundleHandler, get_charm_series, is_local_charm -from .charmhub import CharmHub -from .client import client, connection, connector -from .client.overrides import Caveat, Macaroon -from .constraints import parse as parse_constraints -from .constraints import parse_storage_constraints -from .controller import ConnectedController, Controller -from .delta import get_entity_class, get_entity_delta -from .errors import ( +from .. import provisioner, tag, utils +from ..annotationhelper import _get_annotations, _set_annotations +from ..bundle import BundleHandler, get_charm_series, is_local_charm +from ..charmhub import CharmHub +from ..client import client, connection, connector +from ..client._definitions import ApplicationStatus as ApplicationStatus +from ..client._definitions import MachineStatus as MachineStatus +from ..client._definitions import UnitStatus as UnitStatus +from ..client.overrides import Caveat, Macaroon +from ..constraints import parse as parse_constraints +from ..constraints import parse_storage_constraints +from ..controller import ConnectedController, Controller +from ..delta import get_entity_class, get_entity_delta +from ..errors import ( JujuAgentError, JujuAPIError, JujuAppError, @@ -49,27 +55,37 @@ JujuUnitError, PylibjujuError, ) -from .exceptions import DeadEntityException -from .names import is_valid_application -from .offerendpoints import ParseError as OfferParseError -from .offerendpoints import parse_local_endpoint, parse_offer_url -from .origin import Channel, Source -from .placement import parse as parse_placement -from .secrets import create_secret_data, read_secret_data -from .tag import application as application_tag -from .url import URL, Schema -from .version import DEFAULT_ARCHITECTURE +from ..exceptions import DeadEntityException +from ..names import is_valid_application +from ..offerendpoints import ParseError as OfferParseError +from ..offerendpoints import parse_local_endpoint, parse_offer_url +from ..origin import Channel, Source +from ..placement import parse as parse_placement +from ..secrets import create_secret_data, read_secret_data +from ..tag import application as application_tag +from ..url import URL, Schema +from ..version import DEFAULT_ARCHITECTURE +from . import _idle if TYPE_CHECKING: - from .application import Application - from .client._definitions import FullStatus - from .constraints import StorageConstraintDict - from .machine import Machine - from .relation import Relation - from .remoteapplication import ApplicationOffer, RemoteApplication - from .unit import Unit + from ..application import Application + from ..client._definitions import FullStatus + from ..constraints import StorageConstraintDict + from ..machine import Machine + from ..relation import Relation + from ..remoteapplication import ApplicationOffer, RemoteApplication + from ..unit import Unit + +log = logger = logging.getLogger(__name__) -log = logging.getLogger(__name__) + +def use_new_wait_for_idle() -> bool: + val = os.getenv("JUJU_NEW_WAIT_FOR_IDLE") + if not val: + return False + if val.isdigit(): + return bool(int(val)) + return val.title() != "False" class _Observer: @@ -632,9 +648,9 @@ class Model: def __init__( self, - max_frame_size=None, - bakery_client=None, - jujudata=None, + max_frame_size: int | None = None, + bakery_client: Any = None, + jujudata: Any = None, ): """Instantiate a new Model. @@ -2664,14 +2680,21 @@ async def get_action_status(self, uuid_or_prefix=None, name=None): results[tag.untag("action-", a.action.tag)] = a.status return results - async def get_status(self, filters=None, utc=False) -> FullStatus: + async def get_status(self, filters=None, utc: bool = False) -> FullStatus: """Return the status of the model. :param str filters: Optional list of applications, units, or machines to include, which can use wildcards ('*'). - :param bool utc: Display time as UTC in RFC3339 format + :param bool utc: Deprecated, display time as UTC in RFC3339 format """ + if utc: + warnings.warn( + "Model.get_status() utc= parameter is deprecated", + DeprecationWarning, + stacklevel=2, + ) + client_facade = client.ClientFacade.from_connection(self.connection()) return await client_facade.FullStatus(patterns=filters) @@ -2998,7 +3021,7 @@ async def _get_source_api(self, url): async def wait_for_idle( self, - apps: list[str] | None = None, + apps: Iterable[str] | None = None, raise_on_error: bool = True, raise_on_blocked: bool = False, wait_for_active: bool = False, @@ -3011,7 +3034,7 @@ async def wait_for_idle( ) -> None: """Wait for applications in the model to settle into an idle state. - :param List[str] apps: Optional list of specific app names to wait on. + :param Iterable[str]|None apps: Optional list of specific app names to wait on. If given, all apps must be present in the model and idle, while other apps in the model can still be busy. If not given, all apps currently in the model must be idle. @@ -3037,6 +3060,7 @@ async def wait_for_idle( units of all apps need to be `idle`. This delay is used to ensure that any pending hooks have a chance to start to avoid false positives. The default is 15 seconds. + Exact behaviour is undefined for very small values and 0. :param float check_freq: How frequently, in seconds, to check the model. The default is every half-second. @@ -3053,6 +3077,21 @@ async def wait_for_idle( going into the idle state. (e.g. useful for scaling down). When set, takes precedence over the `wait_for_units` parameter. """ + if use_new_wait_for_idle(): + await self.new_wait_for_idle( + apps=apps, + raise_on_error=raise_on_error, + raise_on_blocked=raise_on_blocked, + wait_for_active=wait_for_active, + timeout=timeout, + idle_period=idle_period, + check_freq=check_freq, + status=status, + wait_for_at_least_units=wait_for_at_least_units, + wait_for_exact_units=wait_for_exact_units, + ) + return + if wait_for_active: warnings.warn( "wait_for_active is deprecated; use status", @@ -3065,16 +3104,18 @@ async def wait_for_idle( wait_for_at_least_units if wait_for_at_least_units is not None else 1 ) - timeout = timedelta(seconds=timeout) if timeout is not None else None - idle_period = timedelta(seconds=idle_period) + timeout_ = timedelta(seconds=timeout) if timeout is not None else None + idle_period_ = timedelta(seconds=idle_period) start_time = datetime.now() - # Type check against the common error of passing a str for apps - if apps is not None and ( - not isinstance(apps, list) or any(not isinstance(o, str) for o in apps) - ): - raise JujuError(f"Expected a List[str] for apps, given {apps}") - apps = apps or self.applications + if isinstance(apps, (str, bytes, bytearray, memoryview)): + raise TypeError(f"apps must be a Iterable[str], got {apps=}") + + apps_ = list(apps or self.applications) + + if any(not isinstance(o, str) for o in apps_): + raise TypeError(f"apps must be a Iterable[str], got {apps_=}") + idle_times: dict[str, datetime] = {} units_ready: set[str] = set() # The units that are in the desired state last_log_time: datetime | None = None @@ -3110,10 +3151,10 @@ def _raise_for_status(entities: dict[str, list[str]], status: Any): # The list 'busy' is what keeps this loop going, # i.e. it'll stop when busy is empty after all the # units are scanned - busy = [] - errors = {} - blocks = {} - for app_name in apps: + busy: list[str] = [] + errors: dict[str, list[str]] = {} + blocks: dict[str, list[str]] = {} + for app_name in apps_: if app_name not in self.applications: busy.append(app_name + " (missing)") continue @@ -3192,7 +3233,7 @@ def _raise_for_status(entities: dict[str, list[str]], status: Any): now = datetime.now() idle_start = idle_times.setdefault(unit.name, now) - if now - idle_start < idle_period: + if now - idle_start < idle_period_: busy.append( f"{unit.name} [{unit.agent_status}] {unit.workload_status}: {unit.workload_status_message}" ) @@ -3205,14 +3246,87 @@ def _raise_for_status(entities: dict[str, list[str]], status: Any): _raise_for_status(blocks, "blocked") if not busy: break - busy = "\n ".join(busy) - if timeout is not None and datetime.now() - start_time > timeout: - raise asyncio.TimeoutError("Timed out waiting for model:\n" + busy) + if timeout_ is not None and datetime.now() - start_time > timeout_: + raise asyncio.TimeoutError( + "\n ".join(["Timed out waiting for model:", *busy]) + ) if last_log_time is None or datetime.now() - last_log_time > log_interval: - log.info("Waiting for model:\n " + busy) + log.info("\n ".join(["Waiting for model:", *busy])) last_log_time = datetime.now() await asyncio.sleep(check_freq) + async def new_wait_for_idle( + self, + apps: Iterable[str] | None = None, + raise_on_error: bool = True, + raise_on_blocked: bool = False, + wait_for_active: bool = False, + timeout: float | None = 10 * 60, + idle_period: float = 15, + check_freq: float = 0.5, + status: str | None = None, + wait_for_at_least_units: int | None = None, + wait_for_exact_units: int | None = None, + ) -> None: + """Wait for applications in the model to settle into an idle state. + + arguments match those of .wait_for_idle exactly. + """ + if not isinstance(wait_for_exact_units, (int, type(None))): + raise ValueError(f"Must be an int or None, got {wait_for_exact_units=}") + + if isinstance(wait_for_exact_units, int) and wait_for_exact_units < 0: + raise ValueError(f"Must be >=0, got {wait_for_exact_units=}") + + if wait_for_active: + warnings.warn( + "wait_for_active is deprecated; use status", + DeprecationWarning, + stacklevel=3, + ) + status = "active" + + wait_for_units = ( + wait_for_at_least_units if wait_for_at_least_units is not None else 1 + ) + + if isinstance(apps, (str, bytes, bytearray, memoryview)): + raise TypeError(f"apps must be a Iterable[str], got {apps=}") + + apps = frozenset(apps or self.applications) + + if any(not isinstance(o, str) for o in apps): + raise TypeError(f"apps must be a Iterable[str], got {apps=}") + + started = time.monotonic() + deadline = None if timeout is None else started + timeout + loop = _idle.Loop( + apps=apps, + wait_for_exact_units=wait_for_exact_units, + wait_for_units=wait_for_units, + idle_period=idle_period, + ) + + while True: + done = loop.next( + _idle.check( + await self.get_status(), + apps=apps, + raise_on_error=raise_on_error, + raise_on_blocked=raise_on_blocked, + status=status, + ) + ) + + logger.info(f"wait_for_idle start{time.monotonic() - started:+.1f} {done=}") + if done: + break + + if deadline and time.monotonic() > deadline: + raise asyncio.TimeoutError(f"Timed out after {timeout}s") + + await asyncio.sleep(check_freq) + def _create_consume_args(offer, macaroon, controller_info): """Convert a typed object that has been normalised to a overridden typed diff --git a/juju/model/_idle.py b/juju/model/_idle.py new file mode 100644 index 00000000..5bcbb3fd --- /dev/null +++ b/juju/model/_idle.py @@ -0,0 +1,230 @@ +# Copyright 2024 Canonical Ltd. +# Licensed under the Apache V2, see LICENCE file for details. +"""Implementation of Model.wait_for_idle(), analog to `juju wait_for`.""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass +from typing import AbstractSet + +from ..client._definitions import ( + ApplicationStatus, + FullStatus, + MachineStatus, + UnitStatus, +) +from ..errors import JujuAgentError, JujuAppError, JujuMachineError, JujuUnitError + +logger = logging.getLogger(__name__) + + +@dataclass +class CheckStatus: + """Return type check(), represents single loop iteration.""" + + units: set[str] + """All units visible at this point.""" + ready_units: set[str] + """Units with the expected workload status.""" + idle_units: set[str] + """Units with stable (idle) agent status.""" + + +class Loop: + def __init__( + self, + *, + apps: AbstractSet[str], + wait_for_exact_units: int | None = None, + wait_for_units: int, + idle_period: float, + ): + self.apps = apps + self.wait_for_exact_units = wait_for_exact_units + self.wait_for_units = wait_for_units + self.idle_period = idle_period + self.idle_since: dict[str, float] = {} + + def next(self, status: CheckStatus | None) -> bool: + logger.info("wait_for_idle iteration %s", status) + now = time.monotonic() + + if not status: + return False + + expected_idle_since = now - self.idle_period + + for name in status.units: + if name in status.idle_units: + self.idle_since[name] = min( + now, self.idle_since.get(name, float("inf")) + ) + else: + self.idle_since[name] = float("inf") + + if busy := {n for n, t in self.idle_since.items() if t > expected_idle_since}: + logger.info("Waiting for units to be idle enough: %s", busy) + return False + + for app_name in self.apps: + ready_units = [ + n for n in status.ready_units if n.startswith(f"{app_name}/") + ] + if len(ready_units) < self.wait_for_units: + logger.info( + "Waiting for app %r units %s >= %s", + app_name, + len(status.ready_units), + self.wait_for_units, + ) + return False + + if ( + self.wait_for_exact_units is not None + and len(ready_units) != self.wait_for_exact_units + ): + logger.info( + "Waiting for app %r units %s == %s", + app_name, + len(ready_units), + self.wait_for_exact_units, + ) + return False + + return True + + +def check( + full_status: FullStatus, + *, + apps: AbstractSet[str], + raise_on_error: bool, + raise_on_blocked: bool, + status: str | None, +) -> CheckStatus | None: + """A single iteration of a wait_for_idle loop.""" + for app_name in apps: + if not full_status.applications.get(app_name): + logger.info("Waiting for app %r", app_name) + return None + + units: dict[str, UnitStatus] = {} + rv = CheckStatus(set(), set(), set()) + + for app_name in apps: + units.update(app_units(full_status, app_name)) + + if raise_on_error: + check_errors(full_status, apps, units) + + if raise_on_blocked: + check_blocked(full_status, apps, units) + + for app_name in apps: + app = full_status.applications[app_name] + assert isinstance(app, ApplicationStatus) + + for unit_name, unit in app_units(full_status, app_name).items(): + rv.units.add(unit_name) + assert unit.agent_status + assert unit.workload_status + + if unit.agent_status.status == "idle": + rv.idle_units.add(unit_name) + + if not status or unit.workload_status.status == status: + rv.ready_units.add(unit_name) + + return rv + + +def check_errors( + full_status: FullStatus, apps: AbstractSet[str], units: dict[str, UnitStatus] +) -> None: + """Check the full status for error conditions, in this order: + + - Machine error (any unit of any app from apps) + - Agent error (-"-) + - Workload error (-"-) + - App error (any app from apps) + """ + for unit_name, unit in units.items(): + if unit.machine: + machine = full_status.machines[unit.machine] + assert isinstance(machine, MachineStatus) + assert machine.instance_status + if machine.instance_status.status == "error": + raise JujuMachineError( + f"{unit_name!r} machine {unit.machine!r} has errored: {machine.instance_status.info!r}" + ) + + for unit_name, unit in units.items(): + assert unit.agent_status + if unit.agent_status.status == "error": + raise JujuAgentError( + f"{unit_name!r} agent has errored: {unit.agent_status.info!r}" + ) + + for unit_name, unit in units.items(): + assert unit.workload_status + if unit.workload_status.status == "error": + raise JujuUnitError( + f"{unit_name!r} workload has errored: {unit.workload_status.info!r}" + ) + + for app_name in apps: + app = full_status.applications[app_name] + assert isinstance(app, ApplicationStatus) + assert app.status + if app.status.status == "error": + raise JujuAppError(f"{app_name!r} has errored: {app.status.info!r}") + + +def check_blocked( + full_status: FullStatus, apps: AbstractSet[str], units: dict[str, UnitStatus] +) -> None: + """Check the full status for blocked conditions, in this order: + + - Workload blocked (any unit of any app from apps) + - App blocked (any app from apps) + """ + for unit_name, unit in units.items(): + assert unit.workload_status + if unit.workload_status.status == "blocked": + raise JujuUnitError( + f"{unit_name!r} workload is blocked: {unit.workload_status.info!r}" + ) + + for app_name in apps: + app = full_status.applications[app_name] + assert isinstance(app, ApplicationStatus) + assert app.status + if app.status.status == "blocked": + raise JujuAppError(f"{app_name!r} is blocked: {app.status.info!r}") + + +def app_units(full_status: FullStatus, app_name: str) -> dict[str, UnitStatus]: + """Fish out the app's units' status from a FullStatus response.""" + rv: dict[str, UnitStatus] = {} + app = full_status.applications[app_name] + assert isinstance(app, ApplicationStatus) + + if app.subordinate_to: + parent_name = app.subordinate_to[0] + parent = full_status.applications[parent_name] + assert isinstance(parent, ApplicationStatus) + for parent_unit in parent.units.values(): + assert isinstance(parent_unit, UnitStatus) + for name, unit in parent_unit.subordinates.items(): + if not name.startswith(f"{app_name}/"): + continue + assert isinstance(unit, UnitStatus) + rv[name] = unit + else: + for name, unit in app.units.items(): + assert isinstance(unit, UnitStatus) + rv[name] = unit + + return rv diff --git a/juju/unit.py b/juju/unit.py index 4cb8e259..64e14ac6 100644 --- a/juju/unit.py +++ b/juju/unit.py @@ -15,7 +15,9 @@ class Unit(model.ModelEntity): - name: str + @property + def name(self) -> str: + return self.entity_id @property def agent_status(self): diff --git a/juju/version.py b/juju/version.py index b3e36f02..b4c849f9 100644 --- a/juju/version.py +++ b/juju/version.py @@ -6,4 +6,4 @@ DEFAULT_ARCHITECTURE = "amd64" -CLIENT_VERSION = "3.6.0.0" +CLIENT_VERSION = "3.6.1.0" diff --git a/pyproject.toml b/pyproject.toml index b3c2d427..8da6954f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "juju" -version = "3.6.0.0" # Stop-gap until dynamic versioning is done; must be in sync with juju/version.py:CLIENT_VERSION +version = "3.6.1.0" # Stop-gap until dynamic versioning is done; must be in sync with juju/version.py:CLIENT_VERSION description = "Python library for Juju" readme = "docs/readme.rst" license = { file = "LICENSE" } @@ -42,6 +42,7 @@ dev = [ "pytest", "pytest-asyncio", "Twine", + "freezegun", ] docs = [ "sphinx==5.3.0", @@ -226,8 +227,9 @@ ignore = [ # These are tentative include = ["**/*.py"] pythonVersion = "3.8.6" -typeCheckingMode = "strict" -useLibraryCodeForTypes = true +# FIXME understand the difference +#typeCheckingMode = "strict" +#useLibraryCodeForTypes = true reportGeneralTypeIssues = true [tool.pytest.ini_options] diff --git a/setup.py b/setup.py index 93825375..57a713de 100644 --- a/setup.py +++ b/setup.py @@ -40,6 +40,7 @@ "pytest", "pytest-asyncio", "Twine", + "freezegun", ] }, include_package_data=True, diff --git a/tests/unit/data/fullstatus.json b/tests/unit/data/fullstatus.json new file mode 100644 index 00000000..d470a8c1 --- /dev/null +++ b/tests/unit/data/fullstatus.json @@ -0,0 +1,322 @@ +{ + "request-id": 7, + "response": { + "applications": { + "grafana-agent-k8s": { + "base": { + "channel": "22.04/stable", + "name": "ubuntu" + }, + "can-upgrade-to": "", + "charm": "ch:arm64/jammy/grafana-agent-k8s-75", + "charm-channel": "latest/stable", + "charm-profile": "", + "charm-version": "", + "endpoint-bindings": { + "": "alpha", + "certificates": "alpha", + "grafana-cloud-config": "alpha", + "grafana-dashboards-consumer": "alpha", + "grafana-dashboards-provider": "alpha", + "logging-consumer": "alpha", + "logging-provider": "alpha", + "metrics-endpoint": "alpha", + "peers": "alpha", + "receive-ca-cert": "alpha", + "send-remote-write": "alpha", + "tracing": "alpha" + }, + "exposed": false, + "int": 1, + "life": "", + "meter-statuses": null, + "provider-id": "4ecc75be-f038-4452-b1af-640d1b46f1c6", + "public-address": "10.152.183.55", + "relations": { + "peers": [ + "grafana-agent-k8s" + ] + }, + "status": { + "data": {}, + "info": "installing agent", + "kind": "", + "life": "", + "since": "2024-09-30T07:44:15.63582531Z", + "status": "waiting", + "version": "" + }, + "subordinate-to": [], + "units": { + "grafana-agent-k8s/0": { + "address": "10.1.121.164", + "agent-status": { + "data": {}, + "info": "", + "kind": "", + "life": "", + "since": "2024-09-30T07:44:15.469295423Z", + "status": "idle", + "version": "3.5.1" + }, + "charm": "", + "leader": true, + "machine": "", + "opened-ports": [], + "provider-id": "grafana-agent-k8s-0", + "public-address": "", + "subordinates": null, + "workload-status": { + "data": {}, + "info": "Missing incoming (\"requires\") relation: metrics-endpoint|logging-provider|grafana-dashboards-consumer", + "kind": "", + "life": "", + "since": "2024-09-30T07:43:41.649319444Z", + "status": "blocked", + "version": "" + }, + "workload-version": "0.35.2" + } + }, + "workload-version": "0.35.2" + }, + "hexanator": { + "base": { + "channel": "24.04/stable", + "name": "ubuntu" + }, + "can-upgrade-to": "", + "charm": "local:noble/hexanator-1", + "charm-profile": "", + "charm-version": "", + "endpoint-bindings": { + "": "alpha", + "ingress": "alpha", + "rate-limit": "alpha" + }, + "exposed": false, + "int": 1, + "life": "", + "meter-statuses": null, + "provider-id": "b5efccf2-5a15-41a0-af0f-689a8d93a129", + "public-address": "10.152.183.113", + "relations": {}, + "status": { + "data": {}, + "info": "", + "kind": "", + "life": "", + "since": "2024-09-30T00:12:47.878239549Z", + "status": "active", + "version": "" + }, + "subordinate-to": [], + "units": { + "hexanator/0": { + "address": "10.1.121.184", + "agent-status": { + "data": {}, + "info": "", + "kind": "", + "life": "", + "since": "2024-09-30T00:13:16.731257044Z", + "status": "idle", + "version": "3.5.1" + }, + "charm": "", + "leader": true, + "machine": "", + "opened-ports": [], + "provider-id": "hexanator-0", + "public-address": "", + "subordinates": null, + "workload-status": { + "data": {}, + "info": "", + "kind": "", + "life": "", + "since": "2024-09-30T00:12:47.878239549Z", + "status": "active", + "version": "" + }, + "workload-version": "" + } + }, + "workload-version": "" + }, + "mysql-test-app": { + "base": { + "channel": "22.04/stable", + "name": "ubuntu" + }, + "can-upgrade-to": "", + "charm": "ch:arm64/jammy/mysql-test-app-62", + "charm-channel": "latest/edge", + "charm-profile": "", + "charm-version": "", + "endpoint-bindings": { + "": "alpha", + "application-peers": "alpha", + "database": "alpha", + "mysql": "alpha" + }, + "exposed": false, + "int": 2, + "life": "", + "meter-statuses": null, + "provider-id": "4338786a-a337-4779-820d-679a59ba1665", + "public-address": "10.152.183.118", + "relations": { + "application-peers": [ + "mysql-test-app" + ] + }, + "status": { + "data": {}, + "info": "installing agent", + "kind": "", + "life": "", + "since": "2024-09-30T07:48:25.106109123Z", + "status": "waiting", + "version": "" + }, + "subordinate-to": [], + "units": { + "mysql-test-app/0": { + "address": "10.1.121.142", + "agent-status": { + "data": {}, + "info": "", + "kind": "", + "life": "", + "since": "2024-10-01T00:15:03.216904329Z", + "status": "idle", + "version": "3.5.1" + }, + "charm": "", + "leader": true, + "machine": "", + "opened-ports": [], + "provider-id": "mysql-test-app-0", + "public-address": "", + "subordinates": null, + "workload-status": { + "data": {}, + "info": "", + "kind": "", + "life": "", + "since": "2024-09-30T07:47:54.212959856Z", + "status": "waiting", + "version": "" + }, + "workload-version": "0.0.2" + }, + "mysql-test-app/1": { + "address": "10.1.121.190", + "agent-status": { + "data": {}, + "info": "", + "kind": "", + "life": "", + "since": "2024-09-30T23:49:39.923901864Z", + "status": "idle", + "version": "3.5.1" + }, + "charm": "", + "machine": "", + "opened-ports": [], + "provider-id": "mysql-test-app-1", + "public-address": "", + "subordinates": null, + "workload-status": { + "data": {}, + "info": "", + "kind": "", + "life": "", + "since": "2024-09-30T07:47:54.211414881Z", + "status": "waiting", + "version": "" + }, + "workload-version": "0.0.2" + } + }, + "workload-version": "0.0.2" + } + }, + "branches": {}, + "controller-timestamp": "2024-10-01T07:25:22.51380313Z", + "machines": {}, + "model": { + "available-version": "", + "cloud-tag": "cloud-microk8s", + "meter-status": { + "color": "", + "message": "" + }, + "model-status": { + "data": {}, + "info": "", + "kind": "", + "life": "", + "since": "2024-09-27T08:21:45.368693216Z", + "status": "available", + "version": "" + }, + "name": "testm", + "region": "localhost", + "sla": "unsupported", + "type": "caas", + "version": "3.5.1" + }, + "offers": {}, + "relations": [ + { + "endpoints": [ + { + "application": "grafana-agent-k8s", + "name": "peers", + "role": "peer", + "subordinate": false + } + ], + "id": 0, + "interface": "grafana_agent_replica", + "key": "grafana-agent-k8s:peers", + "scope": "global", + "status": { + "data": {}, + "info": "", + "kind": "", + "life": "", + "since": "2024-09-30T07:43:31.018463595Z", + "status": "joined", + "version": "" + } + }, + { + "endpoints": [ + { + "application": "mysql-test-app", + "name": "application-peers", + "role": "peer", + "subordinate": false + } + ], + "id": 1, + "interface": "application-peers", + "key": "mysql-test-app:application-peers", + "scope": "global", + "status": { + "data": {}, + "info": "", + "kind": "", + "life": "", + "since": "2024-09-30T07:47:52.823202648Z", + "status": "joined", + "version": "" + } + } + ], + "remote-applications": {} + } +} diff --git a/tests/unit/data/subordinate-fullstatus.json b/tests/unit/data/subordinate-fullstatus.json new file mode 100644 index 00000000..12c66d70 --- /dev/null +++ b/tests/unit/data/subordinate-fullstatus.json @@ -0,0 +1,254 @@ +{ + "request-id": 2618, + "response": { + "model": { + "name": "test-7442-test-subordinate-units-8b20", + "type": "iaas", + "cloud-tag": "cloud-localhost", + "region": "localhost", + "version": "3.6.0", + "available-version": "", + "model-status": { + "status": "available", + "info": "", + "data": {}, + "since": "2024-12-04T01:41:47.4040454Z", + "kind": "", + "version": "", + "life": "" + }, + "meter-status": {"color": "", "message": ""}, + "sla": "unsupported" + }, + "machines": { + "0": { + "agent-status": { + "status": "started", + "info": "", + "data": {}, + "since": "2024-12-04T01:43:51.558449988Z", + "kind": "", + "version": "3.6.0", + "life": "" + }, + "instance-status": { + "status": "running", + "info": "Running", + "data": {}, + "since": "2024-12-04T01:42:38.710685177Z", + "kind": "", + "version": "", + "life": "" + }, + "modification-status": { + "status": "applied", + "info": "", + "data": {}, + "since": "2024-12-04T01:42:26.414748546Z", + "kind": "", + "version": "", + "life": "" + }, + "hostname": "juju-eb2c2c-0", + "dns-name": "10.149.76.219", + "ip-addresses": ["10.149.76.219"], + "instance-id": "juju-eb2c2c-0", + "display-name": "", + "base": {"name": "ubuntu", "channel": "22.04/stable"}, + "id": "0", + "network-interfaces": { + "eth0": { + "ip-addresses": ["10.149.76.219"], + "mac-address": "00:16:3e:06:13:5f", + "gateway": "10.149.76.1", + "space": "alpha", + "is-up": true + } + }, + "containers": {}, + "constraints": "arch=amd64", + "hardware": "arch=amd64 cores=0 mem=0M virt-type=container", + "jobs": ["JobHostUnits"], + "has-vote": false, + "wants-vote": false + } + }, + "applications": { + "ntp": { + "charm": "ch:amd64/ntp-50", + "charm-version": "cs-ntp-team-ntp-4-171-g669ff59", + "charm-profile": "", + "charm-channel": "latest/stable", + "charm-rev": 50, + "base": {"name": "ubuntu", "channel": "20.04/stable"}, + "exposed": false, + "life": "", + "relations": {"juju-info": ["ubuntu"], "ntp-peers": ["ntp"]}, + "can-upgrade-to": "", + "subordinate-to": ["ubuntu"], + "units": null, + "meter-statuses": null, + "status": { + "status": "active", + "info": "chrony: Ready, Failed to disable Hyper-V host sync", + "data": {}, + "since": "2024-12-04T01:44:40.346093963Z", + "kind": "", + "version": "", + "life": "" + }, + "workload-version": "4.2", + "endpoint-bindings": { + "": "alpha", + "juju-info": "alpha", + "master": "alpha", + "nrpe-external-master": "alpha", + "ntp-peers": "alpha", + "ntpmaster": "alpha" + }, + "public-address": "" + }, + "ubuntu": { + "charm": "ch:amd64/ubuntu-25", + "charm-version": "", + "charm-profile": "", + "charm-channel": "latest/stable", + "charm-rev": 25, + "base": {"name": "ubuntu", "channel": "22.04/stable"}, + "exposed": false, + "life": "", + "relations": {"juju-info": ["ntp"]}, + "can-upgrade-to": "", + "subordinate-to": [], + "units": { + "ubuntu/0": { + "agent-status": { + "status": "idle", + "info": "", + "data": {}, + "since": "2024-12-04T01:44:44.342778729Z", + "kind": "", + "version": "3.6.0", + "life": "" + }, + "workload-status": { + "status": "active", + "info": "", + "data": {}, + "since": "2024-12-04T01:43:53.391031729Z", + "kind": "", + "version": "", + "life": "" + }, + "workload-version": "22.04", + "machine": "0", + "opened-ports": null, + "public-address": "10.149.76.219", + "charm": "", + "subordinates": { + "ntp/0": { + "agent-status": { + "status": "idle", + "info": "", + "data": {}, + "since": "2024-12-04T01:44:47.418242454Z", + "kind": "", + "version": "3.6.0", + "life": "" + }, + "workload-status": { + "status": "active", + "info": "chrony: Ready, Failed to disable Hyper-V host sync", + "data": {}, + "since": "2024-12-04T01:44:40.346093963Z", + "kind": "", + "version": "", + "life": "" + }, + "workload-version": "4.2", + "machine": "", + "opened-ports": ["123/udp"], + "public-address": "10.149.76.219", + "charm": "", + "subordinates": null, + "leader": true + } + }, + "leader": true + } + }, + "meter-statuses": null, + "status": { + "status": "active", + "info": "", + "data": {}, + "since": "2024-12-04T01:43:53.391031729Z", + "kind": "", + "version": "", + "life": "" + }, + "workload-version": "22.04", + "endpoint-bindings": null, + "public-address": "" + } + }, + "remote-applications": {}, + "offers": {}, + "relations": [ + { + "id": 0, + "key": "ntp:ntp-peers", + "interface": "ntp", + "scope": "global", + "endpoints": [ + { + "application": "ntp", + "name": "ntp-peers", + "role": "peer", + "subordinate": false + } + ], + "status": { + "status": "joined", + "info": "", + "data": {}, + "since": "2024-12-04T01:44:43.940973679Z", + "kind": "", + "version": "", + "life": "" + } + }, + { + "id": 1, + "key": "ntp:juju-info ubuntu:juju-info", + "interface": "juju-info", + "scope": "container", + "endpoints": [ + { + "application": "ubuntu", + "name": "juju-info", + "role": "provider", + "subordinate": false + }, + { + "application": "ntp", + "name": "juju-info", + "role": "requirer", + "subordinate": true + } + ], + "status": { + "status": "joined", + "info": "", + "data": {}, + "since": "2024-12-04T01:43:53.72325443Z", + "kind": "", + "version": "", + "life": "" + } + } + ], + "controller-timestamp": "2024-12-04T02:01:53.569630593Z", + "branches": {} + } +} diff --git a/tests/unit/test_idle_check.py b/tests/unit/test_idle_check.py new file mode 100644 index 00000000..c920df53 --- /dev/null +++ b/tests/unit/test_idle_check.py @@ -0,0 +1,274 @@ +# Copyright 2024 Canonical Ltd. +# Licensed under the Apache V2, see LICENCE file for details. +from __future__ import annotations + +import copy +import json +from typing import Any + +import pytest + +from juju.client._definitions import FullStatus +from juju.client.facade import _convert_response +from juju.errors import JujuAgentError, JujuAppError, JujuMachineError, JujuUnitError +from juju.model._idle import CheckStatus, check + + +def test_check_status(full_status: FullStatus, kwargs): + status = check(full_status, **kwargs) + units = { + "grafana-agent-k8s/0", + "hexanator/0", + "mysql-test-app/0", + "mysql-test-app/1", + } + assert status == CheckStatus(units, units, units) + + +def test_check_status_missing_app(full_status: FullStatus, kwargs): + kwargs["apps"] = ["missing", "hexanator"] + status = check(full_status, **kwargs) + assert status is None + + +def test_check_status_is_selective(full_status: FullStatus, kwargs): + kwargs["apps"] = ["hexanator"] + status = check(full_status, **kwargs) + assert status == CheckStatus({"hexanator/0"}, {"hexanator/0"}, {"hexanator/0"}) + + +def test_no_apps(full_status: FullStatus, kwargs): + kwargs["apps"] = [] + status = check(full_status, **kwargs) + assert status == CheckStatus(set(), set(), set()) + + +def test_missing_app(full_status: FullStatus, kwargs): + kwargs["apps"] = ["missing"] + status = check(full_status, **kwargs) + assert status is None + + +def test_no_units(response: dict[str, Any], kwargs): + response["response"]["applications"]["hexanator"]["units"].clear() + kwargs["apps"] = ["hexanator"] + status = check(convert(response), **kwargs) + assert status == CheckStatus(set(), set(), set()) + + +def test_app_error(response: dict[str, Any], kwargs): + app = response["response"]["applications"]["hexanator"] + app["status"]["status"] = "error" + app["status"]["info"] = "big problem" + + kwargs["apps"] = ["hexanator"] + kwargs["raise_on_error"] = True + + with pytest.raises(JujuAppError) as e: + check(convert(response), **kwargs) + + assert "big problem" in str(e) + + +def test_ready_units(full_status: FullStatus, kwargs): + kwargs["apps"] = ["mysql-test-app"] + status = check(full_status, **kwargs) + units = {"mysql-test-app/0", "mysql-test-app/1"} + assert status == CheckStatus(units, units, units) + + +def test_active_units(full_status: FullStatus, kwargs): + kwargs["apps"] = ["mysql-test-app"] + kwargs["status"] = "active" + status = check(full_status, **kwargs) + units = {"mysql-test-app/0", "mysql-test-app/1"} + assert status == CheckStatus(units, ready_units=set(), idle_units=units) + + +def test_ready_unit_requires_idle_agent(response: dict[str, Any], kwargs): + app = response["response"]["applications"]["hexanator"] + app["units"]["hexanator/1"] = copy.deepcopy(app["units"]["hexanator/0"]) + app["units"]["hexanator/1"]["agent-status"]["status"] = "some-other" + + kwargs["apps"] = ["hexanator"] + kwargs["status"] = "active" + + status = check(convert(response), **kwargs) + assert status == CheckStatus( + {"hexanator/0", "hexanator/1"}, + {"hexanator/0", "hexanator/1"}, + idle_units={"hexanator/0"}, + ) + + +def test_ready_unit_requires_workload_status(response: dict[str, Any], kwargs): + app = response["response"]["applications"]["hexanator"] + app["units"]["hexanator/1"] = copy.deepcopy(app["units"]["hexanator/0"]) + app["units"]["hexanator/1"]["workload-status"]["status"] = "some-other" + + kwargs["apps"] = ["hexanator"] + kwargs["status"] = "active" + + status = check(convert(response), **kwargs) + units = {"hexanator/0", "hexanator/1"} + assert status == CheckStatus(units, ready_units={"hexanator/0"}, idle_units=units) + + +def test_agent_error(response: dict[str, Any], kwargs): + app = response["response"]["applications"]["hexanator"] + app["units"]["hexanator/0"]["agent-status"]["status"] = "error" + app["units"]["hexanator/0"]["agent-status"]["info"] = "agent problem" + + kwargs["apps"] = ["hexanator"] + kwargs["raise_on_error"] = True + + with pytest.raises(JujuAgentError) as e: + check(convert(response), **kwargs) + + assert "hexanator/0" in str(e) + assert "agent problem" in str(e) + + +def test_workload_error(response: dict[str, Any], kwargs): + app = response["response"]["applications"]["hexanator"] + app["units"]["hexanator/0"]["workload-status"]["status"] = "error" + app["units"]["hexanator/0"]["workload-status"]["info"] = "workload problem" + + kwargs["apps"] = ["hexanator"] + kwargs["raise_on_error"] = True + + with pytest.raises(JujuUnitError) as e: + check(convert(response), **kwargs) + + assert "hexanator/0" in str(e) + assert "workload problem" in str(e) + + +def test_machine_ok(response: dict[str, Any], kwargs): + app = response["response"]["applications"]["hexanator"] + app["units"]["hexanator/0"]["machine"] = "42" + # https://github.com/dimaqq/juju-schema-analysis/blob/main/schemas-juju-3.5.4.model-user.txt#L3611-L3674 + response["response"]["machines"] = { + "42": { + "instance-status": { + "status": "running", + "info": "RUNNING", + }, + }, + } + + kwargs["apps"] = ["hexanator"] + kwargs["raise_on_error"] = True + + status = check(convert(response), **kwargs) + assert status == CheckStatus({"hexanator/0"}, {"hexanator/0"}, {"hexanator/0"}) + + +def test_machine_error(response: dict[str, Any], kwargs): + app = response["response"]["applications"]["hexanator"] + app["units"]["hexanator/0"]["machine"] = "42" + response["response"]["machines"] = { + "42": { + "instance-status": { + "status": "error", + "info": "Battery low. Try a potato?", + }, + }, + } + + kwargs["apps"] = ["hexanator"] + kwargs["raise_on_error"] = True + + with pytest.raises(JujuMachineError) as e: + check(convert(response), **kwargs) + + assert "potato" in str(e) + + +def test_app_blocked(response: dict[str, Any], kwargs): + app = response["response"]["applications"]["hexanator"] + app["status"]["status"] = "blocked" + app["status"]["info"] = "big problem" + + kwargs["apps"] = ["hexanator"] + kwargs["raise_on_blocked"] = True + + with pytest.raises(JujuAppError) as e: + check(convert(response), **kwargs) + + assert "big problem" in str(e) + + +def test_unit_blocked(response: dict[str, Any], kwargs): + app = response["response"]["applications"]["hexanator"] + app["units"]["hexanator/0"]["workload-status"]["status"] = "blocked" + app["units"]["hexanator/0"]["workload-status"]["info"] = "small problem" + + kwargs["apps"] = ["hexanator"] + kwargs["raise_on_blocked"] = True + + with pytest.raises(JujuUnitError) as e: + check(convert(response), **kwargs) + + assert "small problem" in str(e) + + +def test_no_raise_on(response: dict[str, Any], kwargs): + app = response["response"]["applications"]["hexanator"] + app["units"]["hexanator/0"]["workload-status"]["status"] = "blocked" + app["units"]["hexanator/0"]["workload-status"]["info"] = "small problem" + app["units"]["hexanator/0"]["machine"] = "42" + response["response"]["machines"] = { + "42": { + "instance-status": { + "status": "running", + "info": "RUNNING", + }, + }, + } + + kwargs["apps"] = ["hexanator"] + kwargs["raise_on_blocked"] = False + kwargs["raise_on_error"] = False + + status = check(convert(response), **kwargs) + assert status # didn't raise an exception + + +def test_maintenance(response: dict[str, Any], kwargs): + """Taken from nginx-ingress-integrator-operator integration tests.""" + app = response["response"]["applications"]["hexanator"] + app["status"]["status"] = "maintenance" + app["units"]["hexanator/0"]["workload-status"]["status"] = "maintenance" + + kwargs["apps"] = ["hexanator"] + kwargs["status"] = "maintenance" + + status = check(convert(response), **kwargs) + assert status == CheckStatus({"hexanator/0"}, {"hexanator/0"}, {"hexanator/0"}) + + +@pytest.fixture +def kwargs() -> dict[str, Any]: + return dict( + apps=["hexanator", "grafana-agent-k8s", "mysql-test-app"], + raise_on_error=False, + raise_on_blocked=False, + status=None, + ) + + +@pytest.fixture +def response(pytestconfig: pytest.Config) -> dict[str, Any]: + return json.loads( + (pytestconfig.rootpath / "tests/unit/data/fullstatus.json").read_text() + ) + + +def convert(data: dict[str, Any]) -> FullStatus: + return _convert_response(data, cls=FullStatus) + + +@pytest.fixture +def full_status(response) -> FullStatus: + return convert(response) diff --git a/tests/unit/test_idle_check_subordinate.py b/tests/unit/test_idle_check_subordinate.py new file mode 100644 index 00000000..8e945422 --- /dev/null +++ b/tests/unit/test_idle_check_subordinate.py @@ -0,0 +1,63 @@ +# Copyright 2024 Canonical Ltd. +# Licensed under the Apache V2, see LICENCE file for details. +from __future__ import annotations + +import json +from typing import Any + +import pytest + +from juju.client._definitions import FullStatus +from juju.client.facade import _convert_response +from juju.model._idle import CheckStatus, check + + +def test_subordinate_apps(response: dict[str, Any], kwargs): + status = check(convert(response), **kwargs) + assert status == CheckStatus( + {"ntp/0", "ubuntu/0"}, + {"ntp/0", "ubuntu/0"}, + {"ntp/0", "ubuntu/0"}, + ) + + +def test_subordinate_is_selective(response, kwargs): + subordinates = response["response"]["applications"]["ubuntu"]["units"]["ubuntu/0"][ + "subordinates" + ] + subordinates["some-other/0"] = subordinates["ntp/0"] + status = check(convert(response), **kwargs) + assert status == CheckStatus( + {"ntp/0", "ubuntu/0"}, + {"ntp/0", "ubuntu/0"}, + {"ntp/0", "ubuntu/0"}, + ) + + +@pytest.fixture +def kwargs() -> dict[str, Any]: + return dict( + apps=["ntp", "ubuntu"], + raise_on_error=False, + raise_on_blocked=False, + status=None, + ) + + +@pytest.fixture +def response(pytestconfig: pytest.Config) -> dict[str, Any]: + """Juju rpc response JSON to a FullStatus call.""" + return json.loads( + ( + pytestconfig.rootpath / "tests/unit/data/subordinate-fullstatus.json" + ).read_text() + ) + + +@pytest.fixture +def subordinate_status(response) -> FullStatus: + return convert(response) + + +def convert(data: dict[str, Any]) -> FullStatus: + return _convert_response(data, cls=FullStatus) diff --git a/tests/unit/test_idle_loop.py b/tests/unit/test_idle_loop.py new file mode 100644 index 00000000..ca4132f2 --- /dev/null +++ b/tests/unit/test_idle_loop.py @@ -0,0 +1,109 @@ +# Copyright 2024 Canonical Ltd. +# Licensed under the Apache V2, see LICENCE file for details. +from __future__ import annotations + +from typing import AbstractSet, Iterable + +from freezegun import freeze_time + +from juju.model._idle import CheckStatus, Loop + + +def unroll( + statuses: Iterable[CheckStatus | None], + *, + apps: AbstractSet[str], + wait_for_exact_units: int | None = None, + wait_for_units: int, + idle_period: float, +) -> list[bool]: + loop = Loop( + apps=apps, + wait_for_exact_units=wait_for_exact_units, + wait_for_units=wait_for_units, + idle_period=idle_period, + ) + return [loop.next(s) for s in statuses] + + +def test_wait_for_apps(): + def checks(): + yield None + yield None + + assert unroll( + checks(), + apps={"a"}, + wait_for_units=0, + idle_period=0, + ) == [False, False] + + +def test_at_least_units(): + def checks(): + units = {"u/0", "u/1", "u/2"} + yield CheckStatus(units, ready_units={"u/0"}, idle_units=units) + yield CheckStatus(units, ready_units={"u/0", "u/1"}, idle_units=units) + yield CheckStatus(units, ready_units={"u/0", "u/1", "u/2"}, idle_units=units) + + with freeze_time(): + assert unroll( + checks(), + apps={"u"}, + wait_for_units=2, + idle_period=0, + ) == [False, True, True] + + +def test_for_exact_units(): + units = {"u/0", "u/1", "u/2"} + good = CheckStatus(units, ready_units={"u/1", "u/2"}, idle_units=units) + too_few = CheckStatus(units, ready_units={"u/2"}, idle_units=units) + too_many = CheckStatus(units, ready_units={"u/1", "u/2", "u/0"}, idle_units=units) + + def checks(): + yield too_few + yield good + yield too_many + yield good + + assert unroll( + checks(), + apps={"u"}, + wait_for_units=1, + wait_for_exact_units=2, + idle_period=0, + ) == [False, True, False, True] + + +def test_idle_ping_pong(): + good = CheckStatus({"hexanator/0"}, {"hexanator/0"}, idle_units={"hexanator/0"}) + bad = CheckStatus({"hexanator/0"}, {"hexanator/0"}, idle_units=set()) + + def checks(): + with freeze_time() as clock: + for status in [good, bad, good, bad]: + yield status + clock.tick(10) + + assert unroll( + checks(), + apps={"hexanator"}, + wait_for_units=1, + idle_period=15, + ) == [False, False, False, False] + + +def test_idle_period(): + def checks(): + with freeze_time() as clock: + for _ in range(4): + yield CheckStatus({"hexanator/0"}, {"hexanator/0"}, {"hexanator/0"}) + clock.tick(10) + + assert unroll( + checks(), + apps={"hexanator"}, + wait_for_units=1, + idle_period=15, + ) == [False, False, True, True] diff --git a/tests/unit/test_model.py b/tests/unit/test_model.py index 09104321..cde663a5 100644 --- a/tests/unit/test_model.py +++ b/tests/unit/test_model.py @@ -11,7 +11,7 @@ from juju.application import Application from juju.client.jujudata import FileJujuData -from juju.errors import JujuConnectionError, JujuError +from juju.errors import JujuConnectionError from juju.model import Model @@ -251,19 +251,19 @@ async def test_no_args(self): # no apps so should return right away await m.wait_for_idle(wait_for_active=True) - async def test_apps_no_lst(self): + async def test_apps_type(self): m = Model() - with self.assertRaises(JujuError): + with self.assertRaises(TypeError): # apps arg has to be a List[str] await m.wait_for_idle(apps="should-be-list") - with self.assertRaises(JujuError): + with self.assertRaises(TypeError): # apps arg has to be a List[str] - await m.wait_for_idle(apps=3) + await m.wait_for_idle(apps=3) # type: ignore - with self.assertRaises(JujuError): + with self.assertRaises(TypeError): # apps arg has to be a List[str] - await m.wait_for_idle(apps=[3]) + await m.wait_for_idle(apps=[3]) # type: ignore async def test_timeout(self): m = Model() @@ -271,7 +271,8 @@ async def test_timeout(self): # no apps so should timeout after timeout period await m.wait_for_idle(apps=["nonexisting_app"]) self.assertEqual( - str(cm.exception), "Timed out waiting for model:\nnonexisting_app (missing)" + str(cm.exception), + "Timed out waiting for model:\n nonexisting_app (missing)", ) @pytest.mark.wait_for_idle diff --git a/tests/unit/test_unit.py b/tests/unit/test_unit.py index 432b5fd1..4023a170 100644 --- a/tests/unit/test_unit.py +++ b/tests/unit/test_unit.py @@ -81,7 +81,7 @@ async def test_unit_is_leader(mock_cf): client_facade.FullStatus = mock.AsyncMock(return_value=status) unit = Unit("test", model) - unit.name = test["unit"] + unit.entity_id = test["unit"] rval = await unit.is_leader_from_status() assert rval == test["rval"] diff --git a/tox.ini b/tox.ini index d284673b..5356d45f 100644 --- a/tox.ini +++ b/tox.ini @@ -17,6 +17,7 @@ passenv = HOME TEST_AGENTS LXD_DIR + JUJU_NEW_WAIT_FOR_IDLE [testenv:docs] deps = @@ -42,6 +43,7 @@ commands = envdir = {toxworkdir}/py3 commands = pytest \ + --log-level=DEBUG \ --tb native \ -m 'not serial' \ {posargs} \ @@ -59,7 +61,7 @@ commands = # it doesn't get run in CI envdir = {toxworkdir}/py3 commands = - pytest --tb native -s {posargs:-m 'serial'} + pytest --tb native {posargs:-m 'serial'} [testenv:validate] envdir = {toxworkdir}/validate