|
3 | 3 | import logging |
4 | 4 | import os |
5 | 5 | import time |
| 6 | +import re |
| 7 | +import copy |
| 8 | +from collections import namedtuple |
6 | 9 |
|
7 | 10 | import kubernetes |
8 | 11 | import kubernetes.dynamic.exceptions as kexc |
@@ -54,6 +57,96 @@ def clean_openshift_metadata(obj): |
54 | 57 | return obj |
55 | 58 |
|
56 | 59 |
|
| 60 | +def parse_quota_value(quota_str: str | None, attr: str) -> int | None: |
| 61 | + PATTERN = r"([0-9]+)(m|k|Ki|Mi|Gi|Ti|Pi|Ei|K|M|G|T|P|E)?" |
| 62 | + |
| 63 | + suffix = { |
| 64 | + "Ki": 2**10, |
| 65 | + "Mi": 2**20, |
| 66 | + "Gi": 2**30, |
| 67 | + "Ti": 2**40, |
| 68 | + "Pi": 2**50, |
| 69 | + "Ei": 2**60, |
| 70 | + "m": 10**-3, |
| 71 | + "k": 10**3, |
| 72 | + "K": 10**3, |
| 73 | + "M": 10**6, |
| 74 | + "G": 10**9, |
| 75 | + "T": 10**12, |
| 76 | + "P": 10**15, |
| 77 | + "E": 10**18, |
| 78 | + } |
| 79 | + |
| 80 | + if quota_str and quota_str != "0": |
| 81 | + result = re.search(PATTERN, quota_str) |
| 82 | + |
| 83 | + if result is None: |
| 84 | + raise ValueError(f"Unable to parse quota_str = '{quota_str}' for {attr}") |
| 85 | + |
| 86 | + value = int(result.groups()[0]) |
| 87 | + unit = result.groups()[1] |
| 88 | + |
| 89 | + # Convert to number i.e. without any unit suffix |
| 90 | + |
| 91 | + if unit is not None: |
| 92 | + quota_str = value * suffix[unit] |
| 93 | + else: |
| 94 | + quota_str = value |
| 95 | + |
| 96 | + # Convert some attributes to units that coldfront uses |
| 97 | + |
| 98 | + if "RAM" in attr: |
| 99 | + quota_str = round(quota_str / suffix["Mi"]) |
| 100 | + elif "Storage" in attr: |
| 101 | + quota_str = round(quota_str / suffix["Gi"]) |
| 102 | + elif quota_str and quota_str == "0": |
| 103 | + quota_str = 0 |
| 104 | + |
| 105 | + return quota_str |
| 106 | + |
| 107 | + |
| 108 | +LimitRangeDifference = namedtuple("LimitRangeDifference", ["key", "expected", "actual"]) |
| 109 | + |
| 110 | + |
| 111 | +def limit_ranges_diff( |
| 112 | + expected_lr_list: list[dict], actual_lr_list: list[dict] |
| 113 | +) -> list[LimitRangeDifference]: |
| 114 | + expected_lr = copy.deepcopy(expected_lr_list[0]) |
| 115 | + actual_lr = copy.deepcopy(actual_lr_list[0]) |
| 116 | + differences = [] |
| 117 | + |
| 118 | + for key in expected_lr | actual_lr: |
| 119 | + if key == "type": |
| 120 | + if actual_lr.get(key) != expected_lr.get(key): |
| 121 | + differences.append( |
| 122 | + LimitRangeDifference( |
| 123 | + key, expected_lr.get(key), actual_lr.get("type") |
| 124 | + ) |
| 125 | + ) |
| 126 | + break |
| 127 | + continue |
| 128 | + |
| 129 | + # Extra fields in actual limit range, so else statement should only be expected fields |
| 130 | + if key not in expected_lr: |
| 131 | + differences.append(LimitRangeDifference(key, None, actual_lr[key])) |
| 132 | + else: |
| 133 | + for resource in expected_lr.setdefault(key, {}) | actual_lr.setdefault( |
| 134 | + key, {} |
| 135 | + ): |
| 136 | + expected_value = parse_quota_value( |
| 137 | + expected_lr[key].get(resource), resource |
| 138 | + ) |
| 139 | + actual_value = parse_quota_value(actual_lr[key].get(resource), resource) |
| 140 | + if expected_value != actual_value: |
| 141 | + differences.append( |
| 142 | + LimitRangeDifference( |
| 143 | + f"{key},{resource}", expected_value, actual_value |
| 144 | + ) |
| 145 | + ) |
| 146 | + |
| 147 | + return differences |
| 148 | + |
| 149 | + |
57 | 150 | class ApiException(Exception): |
58 | 151 | def __init__(self, message): |
59 | 152 | self.message = message |
@@ -130,6 +223,40 @@ def get_resource_api(self, api_version: str, kind: str): |
130 | 223 | ) |
131 | 224 | return api |
132 | 225 |
|
| 226 | + def set_project_configuration(self, project_id, dry_run=False): |
| 227 | + def _recreate_limitrange(): |
| 228 | + if not dry_run: |
| 229 | + self._openshift_delete_limits(project_id) |
| 230 | + self._openshift_create_limits(project_id) |
| 231 | + logger.info(f"Recreated LimitRanges for namespace {project_id}.") |
| 232 | + |
| 233 | + limits = self._openshift_get_limits(project_id).get("items", []) |
| 234 | + |
| 235 | + if not limits: |
| 236 | + if not dry_run: |
| 237 | + self._openshift_create_limits(project_id) |
| 238 | + logger.info(f"Created default LimitRange for namespace {project_id}.") |
| 239 | + |
| 240 | + elif len(limits) > 1: |
| 241 | + logger.warning( |
| 242 | + f"More than one LimitRange found for namespace {project_id}." |
| 243 | + ) |
| 244 | + _recreate_limitrange() |
| 245 | + |
| 246 | + if len(limits) == 1: |
| 247 | + actual_limits = limits[0]["spec"]["limits"] |
| 248 | + if len(actual_limits) != 1: |
| 249 | + logger.info( |
| 250 | + f"LimitRange for more than one object type found for namespace {project_id}." |
| 251 | + ) |
| 252 | + _recreate_limitrange() |
| 253 | + elif differences := limit_ranges_diff(LIMITRANGE_DEFAULTS, actual_limits): |
| 254 | + for difference in differences: |
| 255 | + logger.info( |
| 256 | + f"LimitRange for {project_id} differs {difference.key}: expected {difference.expected} but found {difference.actual}" |
| 257 | + ) |
| 258 | + _recreate_limitrange() |
| 259 | + |
133 | 260 | def create_project(self, suggested_project_name): |
134 | 261 | sanitized_project_name = utils.get_sanitized_project_name( |
135 | 262 | suggested_project_name |
@@ -446,6 +573,13 @@ def _openshift_create_limits(self, project_name, limits=None): |
446 | 573 | } |
447 | 574 | return api.create(body=payload, namespace=project_name).to_dict() |
448 | 575 |
|
| 576 | + def _openshift_delete_limits(self, project_name): |
| 577 | + api = self.get_resource_api(API_CORE, "LimitRange") |
| 578 | + |
| 579 | + limit_ranges = self._openshift_get_limits(project_name) |
| 580 | + for lr in limit_ranges["items"]: |
| 581 | + api.delete(namespace=project_name, name=lr["metadata"]["name"]) |
| 582 | + |
449 | 583 | def _openshift_get_namespace(self, namespace_name): |
450 | 584 | api = self.get_resource_api(API_CORE, "Namespace") |
451 | 585 | return clean_openshift_metadata(api.get(name=namespace_name).to_dict()) |
|
0 commit comments