Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .stestr.conf

This file was deleted.

4 changes: 4 additions & 0 deletions api/azimuth/cluster_api/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions api/azimuth/cluster_api/dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions api/azimuth/cluster_engine/drivers/crd/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", {}),
)


Expand Down
9 changes: 7 additions & 2 deletions api/azimuth/cluster_engine/dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@
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
import yaml

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)
Expand Down Expand Up @@ -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.

Expand All @@ -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),
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions api/azimuth/provider/openstack/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
)
Expand Down
25 changes: 25 additions & 0 deletions api/azimuth/scheduling/util.py
Original file line number Diff line number Diff line change
@@ -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
28 changes: 22 additions & 6 deletions api/azimuth/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

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


Expand Down Expand Up @@ -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
Expand Down
44 changes: 22 additions & 22 deletions api/azimuth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import contextlib
import dataclasses
import datetime
import functools
import logging
import math
Expand All @@ -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__)
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
)
Expand Down
Empty file.
24 changes: 24 additions & 0 deletions api/tests/helpers.py
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions api/tests/settings.py
Original file line number Diff line number Diff line change
@@ -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"},
}
6 changes: 0 additions & 6 deletions api/tests/test_placeholder.py

This file was deleted.

Loading
Loading