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/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..297ac7ba --- /dev/null +++ b/api/azimuth/scheduling/util.py @@ -0,0 +1,25 @@ +import datetime as dt + +from ..settings import cloud_settings # noqa: TID252 + + +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/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/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, ) 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/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/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..21c4b3e8 100644 --- a/tox.ini +++ b/tox.ini @@ -11,17 +11,16 @@ 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 = 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 @@ -29,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 @@ -42,14 +41,14 @@ 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] -commands = mypy {tox_root}/api {posargs} \ No newline at end of file +commands = mypy {tox_root}/api {posargs} 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..dc6fce67 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 (