From e7339d1a3ad61eae1719ccd3954f6a58d3229bc0 Mon Sep 17 00:00:00 2001 From: Alex Manning Date: Mon, 13 Jul 2026 13:30:51 +0100 Subject: [PATCH 1/5] Handle validation of max_cluster_lifetime_hours --- api/azimuth/cluster_api/base.py | 4 ++ api/azimuth/cluster_api/dto.py | 2 + .../cluster_engine/drivers/crd/driver.py | 7 +-- api/azimuth/cluster_engine/dto.py | 9 +++- api/azimuth/provider/openstack/provider.py | 7 +-- api/azimuth/scheduling/util.py | 25 +++++++++++ api/azimuth/views.py | 44 +++++++++---------- 7 files changed, 68 insertions(+), 30 deletions(-) create mode 100644 api/azimuth/scheduling/util.py diff --git a/api/azimuth/cluster_api/base.py b/api/azimuth/cluster_api/base.py index 2b9cb509..5cf59140 100644 --- a/api/azimuth/cluster_api/base.py +++ b/api/azimuth/cluster_api/base.py @@ -16,6 +16,7 @@ from ..provider import errors as cloud_errors # noqa: TID252 from ..scheduling import dto as scheduling_dto # noqa: TID252 from ..scheduling import k8s as scheduling_k8s # noqa: TID252 +from ..scheduling import util as scheduling_util # noqa: TID252 from . import dto, errors logger = logging.getLogger(__name__) @@ -151,6 +152,9 @@ def _from_api_cluster_template(self, ct): or 0, ct.spec.get("tags", []), dateutil.parser.parse(ct.metadata["creationTimestamp"]), + scheduling_util.lifetime_from_annotations( + ct.metadata.get("annotations", {}) + ), ) @convert_exceptions diff --git a/api/azimuth/cluster_api/dto.py b/api/azimuth/cluster_api/dto.py index 83f1d5eb..bd5da73b 100644 --- a/api/azimuth/cluster_api/dto.py +++ b/api/azimuth/cluster_api/dto.py @@ -32,6 +32,8 @@ class ClusterTemplate: tags: list[str] #: The datetime at which the template was created created_at: datetime.datetime + # The maximum allowable lifetime for clusters + max_lifetime: datetime.timedelta | None @dataclasses.dataclass(frozen=True) diff --git a/api/azimuth/cluster_engine/drivers/crd/driver.py b/api/azimuth/cluster_engine/drivers/crd/driver.py index 47ec5684..e642d8ef 100644 --- a/api/azimuth/cluster_engine/drivers/crd/driver.py +++ b/api/azimuth/cluster_engine/drivers/crd/driver.py @@ -34,9 +34,10 @@ def get_k8s_client(ctx: dto.Context, ensure_namespace: bool = False): def _get_cluster_type_dto(raw): if raw.get("status") and raw.get("status", {}).get("phase") == "Available": return dto.ClusterType.from_dict( - raw.metadata.name, - raw.status.uiMeta, - raw.metadata.resourceVersion, + name=raw.metadata.name, + spec=raw.status.uiMeta, + version=raw.metadata.resourceVersion, + annotations=raw.metadata.get("annotations", {}), ) diff --git a/api/azimuth/cluster_engine/dto.py b/api/azimuth/cluster_engine/dto.py index 0f29b79d..5ee01322 100644 --- a/api/azimuth/cluster_engine/dto.py +++ b/api/azimuth/cluster_engine/dto.py @@ -8,7 +8,7 @@ import re from collections.abc import Mapping, Sequence from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timedelta from typing import Any import requests @@ -16,6 +16,7 @@ from ..provider import dto as cloud_dto # noqa: TID252 from ..scheduling import dto as scheduling_dto # noqa: TID252 +from ..scheduling import util as scheduling_util # noqa: TID252 @dataclass(frozen=True) @@ -105,9 +106,11 @@ class ClusterType: usage_template: str | None #: Used by the Azimuth CRD to support patching version: str | None + # The maximum allowable lifetime for clusters + max_lifetime: timedelta | None @classmethod - def from_dict(cls, name, spec, version=None): + def from_dict(cls, name, spec, version=None, *, annotations): """ Returns a new cluster type from the given dictionary specification. @@ -117,6 +120,7 @@ def from_dict(cls, name, spec, version=None): Returns: A :py:class:`ClusterType`. """ + # Parse the max lifetime from the annotations on the clustertype. return cls( name, spec.get("label", name), @@ -149,6 +153,7 @@ def from_dict(cls, name, spec, version=None): ), spec.get("usage_template", spec.get("usageTemplate", None)), version, + scheduling_util.lifetime_from_annotations(annotations), ) @classmethod diff --git a/api/azimuth/provider/openstack/provider.py b/api/azimuth/provider/openstack/provider.py index 41a88f18..f7ab7026 100644 --- a/api/azimuth/provider/openstack/provider.py +++ b/api/azimuth/provider/openstack/provider.py @@ -502,9 +502,10 @@ def _get_coral_quotas(self): active_allocation_list = list( filter( - lambda a: parse_time_and_correct_tz(a["start"], target_tz) - < current_time - and current_time < parse_time_and_correct_tz(a["end"], target_tz), + lambda a: ( + parse_time_and_correct_tz(a["start"], target_tz) < current_time + and current_time < parse_time_and_correct_tz(a["end"], target_tz) + ), account_allocations, ) ) diff --git a/api/azimuth/scheduling/util.py b/api/azimuth/scheduling/util.py new file mode 100644 index 00000000..8b50ccda --- /dev/null +++ b/api/azimuth/scheduling/util.py @@ -0,0 +1,25 @@ +import datetime as dt + +from .settings import cloud_settings + + +def lifetime_from_annotations(annotations: dict[str, str]) -> dt.timedelta | None: + if lifetime_hours := annotations.get( + "scheduling.azimuth.stackhpc.com/max-duration-hours", False + ): + return dt.timedelta(hours=int(lifetime_hours)) + return None + + +def check_max_platform_duration(platform_data, max_lifetime: dt.timedelta) -> bool: + """Validate that the requested lifetime of the cluster is within policy.""" + # If scheduling is disabled, or cluster has no max life, this is always OK. + if max_lifetime is None or not cloud_settings.SCHEDULING.ENABLED: + return True + end_time = platform_data["schedule"].end_time + now = dt.datetime.now(tz=dt.timezone.utc) + duration = end_time - now + if duration < max_lifetime: + return True + else: + return False diff --git a/api/azimuth/views.py b/api/azimuth/views.py index 9333f262..016d602a 100644 --- a/api/azimuth/views.py +++ b/api/azimuth/views.py @@ -4,7 +4,6 @@ import contextlib import dataclasses -import datetime import functools import logging import math @@ -26,6 +25,7 @@ from .cluster_engine import errors as cluster_engine_errors from .keystore import errors as keystore_errors from .provider import errors as provider_errors +from .scheduling import util as scheduling_util from .settings import cloud_settings log = logging.getLogger(__name__) @@ -1245,12 +1245,19 @@ def clusters(request, tenant): ) input_serializer.is_valid(raise_exception=True) - if not _check_max_platform_duration(input_serializer.validated_data): + cluster_type = input_serializer.validated_data["cluster_type"] + + if not scheduling_util.check_max_platform_duration( + platform_data=input_serializer.validated_data, + max_lifetime=cluster_type.max_lifetime, + ): + max_life_hours: float = ( + cluster_type.max_lifetime.total_seconds() / 3600 + ) return response.Response( { "detail": "Platform exceeds max duration of " - + str(cloud_settings.SCHEDULING.MAX_PLATFORM_DURATION_HOURS) - + " hours." + f"{max_life_hours} hours." }, status=status.HTTP_409_CONFLICT, ) @@ -1495,21 +1502,6 @@ def kubernetes_cluster_template_details(request, tenant, template): return response.Response(serializer.data) -def _check_max_platform_duration(platform_data): - if ( - cloud_settings.SCHEDULING.MAX_PLATFORM_DURATION_HOURS is None - or not cloud_settings.SCHEDULING.ENABLED - ): - return True - end_time = platform_data["schedule"].end_time - now = datetime.datetime.now(tz=datetime.timezone.utc) - duration = (end_time - now).total_seconds() / 3600 - if duration < cloud_settings.SCHEDULING.MAX_PLATFORM_DURATION_HOURS: - return True - else: - return False - - def kubernetes_cluster_check_quotas(session, cluster, template, **data): """ Check the quotas for a Kubernetes cluster. @@ -1669,12 +1661,20 @@ def kubernetes_clusters(request, tenant): context={"session": session, "capi_session": capi_session}, ) input_serializer.is_valid(raise_exception=True) - if not _check_max_platform_duration(input_serializer.validated_data): + + cluster_template = input_serializer.validated_data["cluster_template"] + + if not scheduling_util.check_max_platform_duration( + platform_data=input_serializer.validated_data, + max_lifetime=cluster_template.max_lifetime, + ): + max_life_hours: float = ( + cluster_template.max_lifetime.total_seconds() / 3600 + ) return response.Response( { "detail": "Platform exceeds max duration of " - + str(cloud_settings.SCHEDULING.MAX_PLATFORM_DURATION_HOURS) - + " hours." + f"{max_life_hours} hours." }, status=status.HTTP_409_CONFLICT, ) From def10fbc4d8e3926d35e7f050d2f4170b77e4e5e Mon Sep 17 00:00:00 2001 From: Alex Manning Date: Wed, 15 Jul 2026 15:37:29 +0100 Subject: [PATCH 2/5] Pass max lifetime through to the create box. --- api/azimuth/scheduling/util.py | 2 +- .../pages/tenancy/platforms/clusters/form.js | 1 + .../tenancy/platforms/kubernetes/form.js | 5 +++ .../pages/tenancy/platforms/scheduling.js | 32 +++++++++++++++++-- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/api/azimuth/scheduling/util.py b/api/azimuth/scheduling/util.py index 8b50ccda..9ccd406a 100644 --- a/api/azimuth/scheduling/util.py +++ b/api/azimuth/scheduling/util.py @@ -1,6 +1,6 @@ import datetime as dt -from .settings import cloud_settings +from ..settings import cloud_settings def lifetime_from_annotations(annotations: dict[str, str]) -> dt.timedelta | None: diff --git a/ui/src/components/pages/tenancy/platforms/clusters/form.js b/ui/src/components/pages/tenancy/platforms/clusters/form.js index 12f13f05..a9d1aff8 100644 --- a/ui/src/components/pages/tenancy/platforms/clusters/form.js +++ b/ui/src/components/pages/tenancy/platforms/clusters/form.js @@ -198,6 +198,7 @@ export const ClusterForm = ({ isEdit={formState.isEdit} onCancel={handleCancel} onConfirm={handleConfirm} + maxLifetimeSeconds={formState.clusterType.max_lifetime} /> )} diff --git a/ui/src/components/pages/tenancy/platforms/kubernetes/form.js b/ui/src/components/pages/tenancy/platforms/kubernetes/form.js index 36e25866..2b054f8c 100644 --- a/ui/src/components/pages/tenancy/platforms/kubernetes/form.js +++ b/ui/src/components/pages/tenancy/platforms/kubernetes/form.js @@ -22,6 +22,8 @@ import { import Cookies from 'js-cookie'; +import get from 'lodash/get'; + import { Error, Form, Field, withCustomValidity } from '../../../../utils'; import { @@ -452,6 +454,8 @@ export const KubernetesClusterForm = ({ const handleCancel = () => setShowScheduling(false); const handleConfirm = schedule => onSubmit({ ...formState.data, schedule }); + const selectedTemplate = get(kubernetesClusterTemplates.data, formState.data.template); + return ( <>
)} diff --git a/ui/src/components/pages/tenancy/platforms/scheduling.js b/ui/src/components/pages/tenancy/platforms/scheduling.js index 009a6fa5..fcac117c 100644 --- a/ui/src/components/pages/tenancy/platforms/scheduling.js +++ b/ui/src/components/pages/tenancy/platforms/scheduling.js @@ -89,7 +89,7 @@ const ProjectedQuotas = ({ quotas }) => { const PLATFORM_DURATION_UNITS = ["hours", "days"]; -const PlatformDurationControl = ({ isInvalid, value, onChange, ...props }) => { +const PlatformDurationControl = ({ isInvalid, value, onChange, maxLifetimeSeconds, ...props }) => { const [count, setCount] = useState(value ? value.count : null); const [units, setUnits] = useState(value ? value.units : "days"); @@ -103,12 +103,30 @@ const PlatformDurationControl = ({ isInvalid, value, onChange, ...props }) => { [count, units] ); + // Work out the max value allowed for the currently selected unit, if any + const maxHours = maxLifetimeSeconds != null ? maxLifetimeSeconds / 3600 : null; + const max = maxHours != null ? + (units === "hours" ? Math.floor(maxHours) : Math.floor(maxHours / 24)) : + undefined; + + // If the max lifetime is less than a day, "days" is not a usable unit + const daysAllowed = maxHours == null || maxHours >= 24; + const availableUnits = daysAllowed ? + PLATFORM_DURATION_UNITS : + PLATFORM_DURATION_UNITS.filter(u => u !== "days"); + + useEffect( + () => { if( !daysAllowed && units === "days" ) setUnits("hours"); }, + [daysAllowed] + ); + return ( { ({label: u, value: u}))} + options={availableUnits.map(u => ({label: u, value: u}))} // Sort the options by their index in the units sortOptions={opts => sortBy(opts, opt => PLATFORM_DURATION_UNITS.indexOf(opt.value))} value={units} @@ -134,12 +152,16 @@ export const PlatformSchedulingModal = ({ useSchedulingData, isEdit, onCancel, - onConfirm + onConfirm, + maxLifetimeSeconds }) => { const { loading, fits, quotas, error } = useSchedulingData(); const [platformDuration, setPlatformDuration] = useState(null); + const maxLifetime = maxLifetimeSeconds != null ? parseFloat(maxLifetimeSeconds) : null; + const maxLifetimeHours = maxLifetime != null ? Math.floor(maxLifetime / 3600) : null; + const handleConfirm = () => { let newSchedule = null; if( platformDuration ) { @@ -170,6 +192,9 @@ export const PlatformSchedulingModal = ({ <> The platform will be automatically deleted after this time.
It can still be manually deleted when no longer required. + {maxLifetimeHours != null && ( + <>
Maximum allowed lifetime: {maxLifetimeHours} hours.
+ )} } > @@ -177,6 +202,7 @@ export const PlatformSchedulingModal = ({ required value={platformDuration} onChange={setPlatformDuration} + maxLifetimeSeconds={maxLifetime} /> )} From 3f4a21c9219fa186e721a9c1d8a18a7dfd652201 Mon Sep 17 00:00:00 2001 From: Alex Manning Date: Wed, 15 Jul 2026 17:27:59 +0100 Subject: [PATCH 3/5] Fix the case where there is no limit set but scheduling is enabled. --- api/azimuth/serializers.py | 28 +++++++-- .../pages/tenancy/platforms/scheduling.js | 59 ++++++++++++------- 2 files changed, 60 insertions(+), 27 deletions(-) diff --git a/api/azimuth/serializers.py b/api/azimuth/serializers.py index 023bab99..7a389b2b 100644 --- a/api/azimuth/serializers.py +++ b/api/azimuth/serializers.py @@ -628,9 +628,7 @@ def validate_cluster_type(self, value): def validate_schedule(self, value): if self.context.get("validate_schedule", True): - if cloud_settings.SCHEDULING.ENABLED and not value: - raise serializers.ValidationError("This field is required.") - elif not cloud_settings.SCHEDULING.ENABLED and value: + if not cloud_settings.SCHEDULING.ENABLED and value: raise serializers.ValidationError("Scheduling is not supported.") return value @@ -644,6 +642,14 @@ def validate(self, data): ) except clusters_errors.ValidationError as exc: raise serializers.ValidationError({"parameter_values": exc.errors}) + # A schedule is only mandatory when the cluster type enforces a max lifetime + if ( + self.context.get("validate_schedule", True) + and cloud_settings.SCHEDULING.ENABLED + and data["cluster_type"].max_lifetime is not None + and not data.get("schedule") + ): + raise serializers.ValidationError({"schedule": "This field is required."}) return data @@ -973,12 +979,22 @@ class CreateKubernetesClusterSerializer( def validate_schedule(self, value): if self.context.get("validate_schedule", True): - if cloud_settings.SCHEDULING.ENABLED and not value: - raise serializers.ValidationError("This field is required.") - elif not cloud_settings.SCHEDULING.ENABLED and value: + if not cloud_settings.SCHEDULING.ENABLED and value: raise serializers.ValidationError("Scheduling is not supported.") return value + def validate(self, data): + data = super().validate(data) + # A schedule is only mandatory when the cluster template enforces a max lifetime + if ( + self.context.get("validate_schedule", True) + and cloud_settings.SCHEDULING.ENABLED + and data["template"].max_lifetime is not None + and not data.get("schedule") + ): + raise serializers.ValidationError({"schedule": "This field is required."}) + return data + class UpdateKubernetesClusterSerializer( KubernetesClusterValidationMixin, serializers.Serializer diff --git a/ui/src/components/pages/tenancy/platforms/scheduling.js b/ui/src/components/pages/tenancy/platforms/scheduling.js index fcac117c..dc6fce67 100644 --- a/ui/src/components/pages/tenancy/platforms/scheduling.js +++ b/ui/src/components/pages/tenancy/platforms/scheduling.js @@ -158,13 +158,16 @@ export const PlatformSchedulingModal = ({ const { loading, fits, quotas, error } = useSchedulingData(); const [platformDuration, setPlatformDuration] = useState(null); + // Only relevant when there is no enforced max lifetime, in which case the + // platform can be created with no auto-deletion time at all + const [noExpiry, setNoExpiry] = useState(false); const maxLifetime = maxLifetimeSeconds != null ? parseFloat(maxLifetimeSeconds) : null; const maxLifetimeHours = maxLifetime != null ? Math.floor(maxLifetime / 3600) : null; const handleConfirm = () => { let newSchedule = null; - if( platformDuration ) { + if( platformDuration && !(maxLifetime == null && noExpiry) ) { // On confirmation, convert the duration to an ISO-formatted end time const duration = { [platformDuration.units]: platformDuration.count }; const endTime = DateTime.now().plus(duration); @@ -185,26 +188,40 @@ export const PlatformSchedulingModal = ({ onSubmit={handleConfirm} > {(isEdit || !supportsScheduling) ? undefined : ( - - The platform will be automatically deleted after this time.
- It can still be manually deleted when no longer required. - {maxLifetimeHours != null && ( - <>
Maximum allowed lifetime: {maxLifetimeHours} hours.
- )} - - } - > - -
+ <> + {!noExpiry && ( + + The platform will be automatically deleted after this time.
+ It can still be manually deleted when no longer required. + {maxLifetimeHours != null && ( + <>
Maximum allowed lifetime: {maxLifetimeHours} hours.
+ )} + + } + > + +
+ )} + {maxLifetime == null && ( + + setNoExpiry(evt.target.checked)} + /> + + )} + )} Platform resource consumption From 1171d3fc1eb5a2eee49d8f947777f19257e33ca4 Mon Sep 17 00:00:00 2001 From: Alex Manning Date: Fri, 17 Jul 2026 11:14:43 +0100 Subject: [PATCH 4/5] Prepare for adding some tests. * Setup the django testing framework for functional tests. * Setup unit tests. * Make the existing acl tests actually run. --- .stestr.conf | 3 --- api/tests/functional/__init__.py | 0 api/tests/helpers.py | 24 +++++++++++++++++++ api/tests/settings.py | 24 +++++++++++++++++++ api/tests/test_placeholder.py | 6 ----- api/tests/unit/__init__.py | 0 api/{azimuth/acls => tests/unit}/test_acls.py | 4 ++-- test-requirements.txt | 3 --- tox.ini | 17 +++++++------ 9 files changed, 58 insertions(+), 23 deletions(-) delete mode 100644 .stestr.conf create mode 100644 api/tests/functional/__init__.py create mode 100644 api/tests/helpers.py create mode 100644 api/tests/settings.py delete mode 100644 api/tests/test_placeholder.py create mode 100644 api/tests/unit/__init__.py rename api/{azimuth/acls => tests/unit}/test_acls.py (99%) diff --git a/.stestr.conf b/.stestr.conf deleted file mode 100644 index bf7a364c..00000000 --- a/.stestr.conf +++ /dev/null @@ -1,3 +0,0 @@ -[DEFAULT] -test_path=./api/tests -top_dir=./ \ No newline at end of file diff --git a/api/tests/functional/__init__.py b/api/tests/functional/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/tests/helpers.py b/api/tests/helpers.py new file mode 100644 index 00000000..c1f7ff19 --- /dev/null +++ b/api/tests/helpers.py @@ -0,0 +1,24 @@ +""" +Shared test helpers. +""" + +from contextlib import contextmanager + +from azimuth.settings import cloud_settings + + +@contextmanager +def override_cloud_settings(**overrides): + """ + Override top-level keys of the ``AZIMUTH`` setting as seen by + ``azimuth.settings.cloud_settings``. + """ + original_user_settings = cloud_settings.user_settings + original_settings_cache = cloud_settings.settings_cache + cloud_settings.user_settings = {**original_user_settings, **overrides} + cloud_settings.settings_cache = {} + try: + yield + finally: + cloud_settings.user_settings = original_user_settings + cloud_settings.settings_cache = original_settings_cache diff --git a/api/tests/settings.py b/api/tests/settings.py new file mode 100644 index 00000000..eef65469 --- /dev/null +++ b/api/tests/settings.py @@ -0,0 +1,24 @@ +"""Settings for running the api test suite.""" + +from pathlib import Path +from unittest import mock + +import easykube +from flexi_settings import include + +# Patch out requirement for kubeconfig at runtime in tests. +mock.patch.object( + easykube.Configuration, + "from_environment", + classmethod(lambda cls, **kwargs: cls(**kwargs)), +).start() + +etc_azimuth = Path(__file__).resolve().parent.parent / "etc" / "azimuth" + +include(etc_azimuth / "defaults.py") +include(etc_azimuth / "app.py") + +AZIMUTH_AUTH = { + "AUTH_TYPE": "openstack", + "OPENSTACK": {"AUTH_URL": "https://openstack.example.test:5000/v3"}, +} diff --git a/api/tests/test_placeholder.py b/api/tests/test_placeholder.py deleted file mode 100644 index 8bb82935..00000000 --- a/api/tests/test_placeholder.py +++ /dev/null @@ -1,6 +0,0 @@ -import unittest - - -class PlaceholderTest(unittest.TestCase): - def test_placeholder(self): - self.assertTrue(True) diff --git a/api/tests/unit/__init__.py b/api/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/azimuth/acls/test_acls.py b/api/tests/unit/test_acls.py similarity index 99% rename from api/azimuth/acls/test_acls.py rename to api/tests/unit/test_acls.py index 742fb25b..fb081b01 100644 --- a/api/azimuth/acls/test_acls.py +++ b/api/tests/unit/test_acls.py @@ -1,7 +1,6 @@ from unittest import TestCase -from ..provider.dto import Tenancy # noqa: TID252 -from .acls import ( +from azimuth.acls.acls import ( ACL_ALLOW_IDS_KEY, ACL_ALLOW_PATTERN_KEY, ACL_DENY_IDS_KEY, @@ -9,6 +8,7 @@ ACL_KEYS, allowed_by_acls, ) +from azimuth.provider.dto import Tenancy class ACLTestCase(TestCase): diff --git a/test-requirements.txt b/test-requirements.txt index f28ca058..b87b02a6 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,8 +1,5 @@ ruff~=0.11.8 coverage>=4.0,!=4.4 # Apache-2.0 -python-subunit>=0.0.18 # Apache-2.0/BSD -stestr>=1.0.0 # Apache-2.0 -testtools>=1.4.0 # MIT codespell autopep8 mypy diff --git a/tox.ini b/tox.ini index 5e34c4da..f2363c43 100644 --- a/tox.ini +++ b/tox.ini @@ -11,12 +11,11 @@ basepython = python3 usedevelop = True setenv = PYTHONWARNINGS=default::DeprecationWarning - OS_STDOUT_CAPTURE=1 - OS_STDERR_CAPTURE=1 - OS_TEST_TIMEOUT=60 + DJANGO_SETTINGS_MODULE=tests.settings +changedir = {toxinidir}/api deps = -r{toxinidir}/api/requirements.txt -r{toxinidir}/test-requirements.txt -commands = stestr run {posargs} +commands = python manage.py test tests {posargs} [testenv:autofix] commands = @@ -42,13 +41,13 @@ commands = {posargs} [testenv:cover] setenv = + {[testenv]setenv} VIRTUAL_ENV={envdir} - PYTHON=coverage run --source api --parallel-mode +changedir = {toxinidir}/api commands = - stestr run {posargs} - coverage combine - coverage html -d cover - coverage xml -o cover/coverage.xml + coverage run --source azimuth,azimuth_auth,azimuth_site manage.py test tests {posargs} + coverage html -d {toxinidir}/cover + coverage xml -o {toxinidir}/cover/coverage.xml coverage report [testenv:mypy] From cf232226f4324a56ed0757700ffc1035a26d8b7c Mon Sep 17 00:00:00 2001 From: Alex Manning Date: Tue, 21 Jul 2026 11:51:27 +0100 Subject: [PATCH 5/5] Add some simple tests for the sceduling utilities. --- api/azimuth/scheduling/util.py | 2 +- api/tests/test_scheduling_util.py | 82 +++++++++++++++++++++++++++++++ tox.ini | 6 +-- 3 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 api/tests/test_scheduling_util.py diff --git a/api/azimuth/scheduling/util.py b/api/azimuth/scheduling/util.py index 9ccd406a..297ac7ba 100644 --- a/api/azimuth/scheduling/util.py +++ b/api/azimuth/scheduling/util.py @@ -1,6 +1,6 @@ import datetime as dt -from ..settings import cloud_settings +from ..settings import cloud_settings # noqa: TID252 def lifetime_from_annotations(annotations: dict[str, str]) -> dt.timedelta | None: diff --git a/api/tests/test_scheduling_util.py b/api/tests/test_scheduling_util.py new file mode 100644 index 00000000..1f15b8c5 --- /dev/null +++ b/api/tests/test_scheduling_util.py @@ -0,0 +1,82 @@ +"""Test scheduling utilities and lifetimes.""" + +import datetime as dt +from types import SimpleNamespace +from unittest import TestCase, mock + +from azimuth.scheduling.util import ( + check_max_platform_duration, + lifetime_from_annotations, +) + +from tests.helpers import override_cloud_settings + +FIXED_NOW = dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc) + + +class LifetimeFromAnnotationsTestCase(TestCase): + """Test serialisation to/from the lifetime annotation.""" + + def test_valid_annotation(self): + annotations = {"scheduling.azimuth.stackhpc.com/max-duration-hours": "12"} + self.assertEqual(lifetime_from_annotations(annotations), dt.timedelta(hours=12)) + + def test_no_annotation(self): + self.assertIsNone(lifetime_from_annotations({})) + + def test_empty_annotation(self): + annotations = {"scheduling.azimuth.stackhpc.com/max-duration-hours": ""} + self.assertIsNone(lifetime_from_annotations(annotations)) + + +class CheckMaxPlatformDurationTestCase(TestCase): + def setUp(self): + # Patch now to be fixed. + patcher = mock.patch("azimuth.scheduling.util.dt.datetime") + mock_datetime = patcher.start() + mock_datetime.now.return_value = FIXED_NOW + self.addCleanup(patcher.stop) + + def platform_data(self, *, end_time): + return {"schedule": SimpleNamespace(end_time=end_time)} + + def test_no_max_lifetime(self): + with override_cloud_settings(SCHEDULING={"ENABLED": True}): + platform_data = self.platform_data( + end_time=FIXED_NOW + dt.timedelta(hours=1000) + ) + self.assertTrue(check_max_platform_duration(platform_data, None)) + + def test_scheduling_disabled(self): + with override_cloud_settings(SCHEDULING={"ENABLED": False}): + platform_data = self.platform_data( + end_time=FIXED_NOW + dt.timedelta(hours=1000) + ) + self.assertTrue( + check_max_platform_duration(platform_data, dt.timedelta(hours=1)) + ) + + def test_duration_within_max(self): + with override_cloud_settings(SCHEDULING={"ENABLED": True}): + platform_data = self.platform_data( + end_time=FIXED_NOW + dt.timedelta(hours=1) + ) + self.assertTrue( + check_max_platform_duration(platform_data, dt.timedelta(hours=12)) + ) + + def test_duration_at_max_boundary(self): + # Existing behaviour- boundary is < not <=. + with override_cloud_settings(SCHEDULING={"ENABLED": True}): + max_lifetime = dt.timedelta(hours=12) + platform_data = self.platform_data(end_time=FIXED_NOW + max_lifetime) + self.assertFalse(check_max_platform_duration(platform_data, max_lifetime)) + + def test_duration_exceeds_max(self): + with override_cloud_settings(SCHEDULING={"ENABLED": True}): + platform_data = self.platform_data( + end_time=FIXED_NOW + dt.timedelta(hours=24) + ) + self.assertFalse( + check_max_platform_duration(platform_data, dt.timedelta(hours=12)) + ) diff --git a/tox.ini b/tox.ini index f2363c43..21c4b3e8 100644 --- a/tox.ini +++ b/tox.ini @@ -20,7 +20,7 @@ commands = python manage.py test tests {posargs} [testenv:autofix] commands = ruff format {tox_root}/api - codespell {tox_root}/api -w --ignore-words=.codespellignore + codespell {tox_root}/api -w --ignore-words={tox_root}/.codespellignore ruff check {tox_root}/api --fix [testenv:black] # TODO: understand why ruff doesn't fix @@ -28,7 +28,7 @@ commands = commands = black {tox_root}/api {posargs} [testenv:codespell] -commands = codespell {tox_root}/api --ignore-words=.codespellignore {posargs} +commands = codespell {tox_root}/api --ignore-words={tox_root}/.codespellignore {posargs} [testenv:ruff] description = Run Ruff checks @@ -51,4 +51,4 @@ commands = coverage report [testenv:mypy] -commands = mypy {tox_root}/api {posargs} \ No newline at end of file +commands = mypy {tox_root}/api {posargs}