diff --git a/helm/crds/resourceclaims.yaml b/helm/crds/resourceclaims.yaml index 6b76617..6aa5b16 100644 --- a/helm/crds/resourceclaims.yaml +++ b/helm/crds/resourceclaims.yaml @@ -188,6 +188,11 @@ spec: Effective timestamp for end of lifespan for ResourceClaim. type: string format: date-time + firstReady: + description: >- + First timestamp when ResourceClaim indicated ready status. + type: string + format: date-time maximum: description: >- Maximum lifespan which may be requested in the ResourceClaim relative to the creation timestamp. diff --git a/helm/templates/crds/resourceclaims.yaml b/helm/templates/crds/resourceclaims.yaml index b655fa8..db2f992 100644 --- a/helm/templates/crds/resourceclaims.yaml +++ b/helm/templates/crds/resourceclaims.yaml @@ -189,6 +189,11 @@ spec: Effective timestamp for end of lifespan for ResourceClaim. type: string format: date-time + firstReady: + description: >- + First timestamp when ResourceClaim indicated ready status. + type: string + format: date-time maximum: description: >- Maximum lifespan which may be requested in the ResourceClaim relative to the creation timestamp. diff --git a/operator/poolboy_templating.py b/operator/poolboy_templating.py index 8c68a41..7cf9902 100644 --- a/operator/poolboy_templating.py +++ b/operator/poolboy_templating.py @@ -81,6 +81,24 @@ def seconds_to_interval(seconds:int) -> str: else: return f"{int(seconds)}s" +def timedelta_to_str(td:timedelta) -> str: + total_seconds = int(td.total_seconds()) + days = total_seconds // 86400 + hours = (total_seconds % 86400) // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + + ret = "" + if days > 0: + ret += f"{days}d" + if hours > 0: + ret += f"{hours:d}h" + if minutes > 0: + ret += f"{minutes}m" + if seconds > 0: + ret += f"{seconds}s" + return ret + jinja2envs = { 'jinja2': jinja2.Environment( finalize = error_if_undefined, diff --git a/operator/resourceclaim.py b/operator/resourceclaim.py index 396e257..0d3dc25 100644 --- a/operator/resourceclaim.py +++ b/operator/resourceclaim.py @@ -193,6 +193,18 @@ def lifespan_end_timestamp(self) -> Optional[str]: if lifespan: return lifespan.get('end') + @property + def lifespan_first_ready_datetime(self) -> Optional[datetime]: + timestamp = self.lifespan_first_ready_timestamp + if timestamp: + return datetime.strptime(timestamp, "%Y-%m-%dT%H:%M:%S%z") + + @property + def lifespan_first_ready_timestamp(self) -> Optional[str]: + timestamp = self.status.get('lifespan', {}).get('firstReady') + if timestamp: + return timestamp + @property def lifespan_maximum(self) -> Optional[str]: lifespan = self.status.get('lifespan') @@ -340,6 +352,10 @@ async def bind_resource_handle(self, "maximum": resource_handle.get_lifespan_maximum(resource_claim=self), "relativeMaximum": resource_handle.get_lifespan_relative_maximum(resource_claim=self), } + + if resource_handle.is_ready: + lifespan_value['firstReady'] = datetime.now(timezone.utc).strftime('%FT%TZ') + status_patch.append({ "op": "add", "path": "/status/lifespan", @@ -405,6 +421,17 @@ def get_resource_state_from_status(self, resource_index): return None return self.status['resources'][resource_index].get('state') + async def set_requested_lifespan_end(self, + end_datetime, + ) -> None: + await self.merge_patch({ + "spec": { + "lifespan": { + "end": end_datetime.strftime('%FT%TZ') + } + } + }) + async def update_status_from_handle(self, logger: kopf.ObjectLogger, resource_handle: ResourceHandleT @@ -412,6 +439,20 @@ async def update_status_from_handle(self, async with self.lock: patch = [] + # Reset lifespan from default on first ready + if resource_handle.is_ready and not self.lifespan_first_ready_timestamp: + logger.info(f"{self} is first ready") + lifespan_default_timedelta = resource_handle.get_lifespan_default_timedelta(resource_claim=self) + if lifespan_default_timedelta: + logger.info(f"{self} has lifespan_default_timedelta") + # Adjust requested end if unchanged from default + if not self.requested_lifespan_end_datetime \ + or lifespan_default_timedelta == self.requested_lifespan_end_datetime - self.lifespan_start_datetime: + logger.info(f"Setting requested lifespan end") + await self.set_requested_lifespan_end( + datetime.now(timezone.utc) + lifespan_default_timedelta + ) + lifespan_maximum = resource_handle.get_lifespan_maximum(resource_claim=self) lifespan_relative_maximum = resource_handle.get_lifespan_relative_maximum(resource_claim=self) if 'lifespan' in resource_handle.spec \ @@ -421,6 +462,8 @@ async def update_status_from_handle(self, "maximum": lifespan_maximum, "relativeMaximum": lifespan_relative_maximum, } + if resource_handle.is_ready: + lifespan_value['firstReady'] = datetime.now(timezone.utc).strftime('%FT%TZ') patch.append({ "op": "add", "path": "/status/lifespan", @@ -441,6 +484,14 @@ async def update_status_from_handle(self, "value": resource_handle.lifespan_end_timestamp, }) + if resource_handle.is_ready and not self.lifespan_first_ready_timestamp: + # FIXME - Reset ResourceHandle lifespan default! + patch.append({ + "op": "add", + "path": "/status/lifespan/firstReady", + "value": datetime.now(timezone.utc).strftime('%FT%TZ'), + }) + if lifespan_maximum: if lifespan_maximum != self.lifespan_maximum: patch.append({ diff --git a/operator/resourcehandle.py b/operator/resourcehandle.py index 8920658..d935bbd 100644 --- a/operator/resourcehandle.py +++ b/operator/resourcehandle.py @@ -18,7 +18,7 @@ from kopfobject import KopfObject from poolboy import Poolboy -from poolboy_templating import recursive_process_template_strings, seconds_to_interval +from poolboy_templating import recursive_process_template_strings, seconds_to_interval, timedelta_to_str ResourceClaimT = TypeVar('ResourceClaimT', bound='ResourceClaim') ResourceHandleT = TypeVar('ResourceHandleT', bound='ResourceHandle') @@ -366,19 +366,17 @@ async def create_for_claim(cls, ) lifespan_end = lifespan_start_datetime + lifespan_maximum_timedelta - if lifespan_end_datetime \ - or lifespan_maximum_timedelta \ - or lifespan_relative_maximum_timedelta: - definition['spec']['lifespan'] = {} + if lifespan_default_timedelta: + definition['spec'].setdefault('lifespan', {})['default'] = timedelta_to_str(lifespan_default_timedelta) if lifespan_end_datetime: - definition['spec']['lifespan']['end'] = lifespan_end_datetime.strftime('%FT%TZ') + definition['spec'].setdefault('lifespan', {})['end'] = lifespan_end_datetime.strftime('%FT%TZ') if lifespan_maximum: - definition['spec']['lifespan']['maximum'] = lifespan_maximum + definition['spec'].setdefault('lifespan', {})['maximum'] = lifespan_maximum if lifespan_relative_maximum: - definition['spec']['lifespan']['relativeMaximum'] = lifespan_relative_maximum + definition['spec'].setdefault('lifespan', {})['relativeMaximum'] = lifespan_relative_maximum definition = await Poolboy.custom_objects_api.create_namespaced_custom_object( body = definition, @@ -785,10 +783,21 @@ def get_lifespan_relative_maximum_timedelta(self, resource_claim=None): def get_lifespan_end_maximum_datetime(self, resource_claim=None): lifespan_start_datetime = resource_claim.lifespan_start_datetime if resource_claim else self.creation_datetime + maximum_timedelta = self.get_lifespan_maximum_timedelta(resource_claim=resource_claim) - maximum_end = lifespan_start_datetime + maximum_timedelta if maximum_timedelta else None + if maximum_timedelta: + if resource_claim.lifespan_first_ready_timestamp: + maximum_end = resource_claim.lifespan_first_ready_datetime + maximum_timedelta + else: + maximum_end = lifespan_start_datetime + maximum_timedelta + else: + maximum_end = None + relative_maximum_timedelta = self.get_lifespan_relative_maximum_timedelta(resource_claim=resource_claim) - relative_maximum_end = datetime.now(timezone.utc) + relative_maximum_timedelta if relative_maximum_timedelta else None + if relative_maximum_timedelta: + relative_maximum_end = datetime.now(timezone.utc) + relative_maximum_timedelta + else: + relative_maximum_end = None if relative_maximum_end \ and (not maximum_end or relative_maximum_end < maximum_end): diff --git a/test/roles/poolboy_test_simple/tasks/test-pool-01.yaml b/test/roles/poolboy_test_simple/tasks/test-pool-01.yaml index a4e75d8..8bfe8c9 100644 --- a/test/roles/poolboy_test_simple/tasks/test-pool-01.yaml +++ b/test/roles/poolboy_test_simple/tasks/test-pool-01.yaml @@ -70,6 +70,8 @@ {{ r_get_resource_handles.resources | json_query('[?spec.resourceClaim==null]') }} failed_when: >- __unbound_handles | length != 2 + retries: 5 + delay: 1 - name: Create ResourceClaim test-pool-01 kubernetes.core.k8s: @@ -131,6 +133,8 @@ {{ r_get_resource_handles.resources | json_query('[?spec.resourceClaim==null]') }} failed_when: >- __unbound_handles | length != 2 + delay: 1 + retries: 5 - name: Delete ResourcePool test-pool-01 kubernetes.core.k8s: diff --git a/test/roles/poolboy_test_simple/tasks/test-ready-01.yaml b/test/roles/poolboy_test_simple/tasks/test-ready-01.yaml new file mode 100644 index 0000000..ee50851 --- /dev/null +++ b/test/roles/poolboy_test_simple/tasks/test-ready-01.yaml @@ -0,0 +1,137 @@ +--- +- name: Create ResourceProvider test-ready-01 + kubernetes.core.k8s: + definition: + apiVersion: "{{ poolboy_domain }}/v1" + kind: ResourceProvider + metadata: + name: test-ready-01 + namespace: "{{ poolboy_namespace }}" + labels: >- + {{ { + poolboy_domain ~ "/test": "simple" + } }} + spec: + healthCheck: >- + spec.numbervalue >= 0 + lifespan: + default: 1d + maximum: 7d + relativeMaximum: 3d + override: + apiVersion: "{{ poolboy_domain }}/v1" + kind: ResourceClaimTest + metadata: + name: "test-ready-01-{% raw %}{{ guid }}{% endraw %}" + namespace: "{{ poolboy_test_namespace }}" + parameters: + - name: numbervar + allowUpdate: true + validation: + openAPIV3Schema: + type: integer + default: 0 + minimum: 0 + readinessCheck: >- + spec.numbervalue > 0 + template: + definition: + spec: + numbervalue: "{% raw %}{{ numbervar | int }}{% endraw %}" + enable: true + updateFilters: + - pathMatch: /spec/.* + allowedOps: + - replace + +- name: Create ResourceClaim test-ready-01 + kubernetes.core.k8s: + definition: + apiVersion: "{{ poolboy_domain }}/v1" + kind: ResourceClaim + metadata: + name: test-ready-01 + namespace: "{{ poolboy_test_namespace }}" + labels: >- + {{ { + poolboy_domain ~ "/test": "simple" + } }} + spec: + provider: + name: test-ready-01 + parameterValues: + numbervar: 0 + +- name: Verify handling of ResourceClaim test-ready-01 + kubernetes.core.k8s_info: + api_version: "{{ poolboy_domain }}/v1" + kind: ResourceClaim + name: test-ready-01 + namespace: "{{ poolboy_test_namespace }}" + register: r_get_resource_claim + failed_when: >- + r_get_resource_claim.resources[0].status.ready != false or + r_get_resource_claim.resources[0].status.lifespan.firstReady is defined or + ( + r_get_resource_claim.resources[0].status.lifespan.end | to_datetime("%Y-%m-%dT%H:%M:%SZ") - + r_get_resource_claim.resources[0].status.lifespan.start | to_datetime("%Y-%m-%dT%H:%M:%SZ") + ).total_seconds() != 24 * 60 * 60 + until: r_get_resource_claim is success + delay: 1 + retries: 10 + +- name: Update ResourceClaim test-ready-01 to become ready + kubernetes.core.k8s: + api_version: "{{ poolboy_domain }}/v1" + kind: ResourceClaim + name: test-ready-01 + namespace: "{{ poolboy_test_namespace }}" + state: patched + definition: + spec: + provider: + parameterValues: + numbervar: 1 + +- name: Pause to ensure timestamp change + pause: + seconds: 1 + +- name: Verify update handling of ResourceClaim test-ready-01 + kubernetes.core.k8s_info: + api_version: "{{ poolboy_domain }}/v1" + kind: ResourceClaim + name: test-ready-01 + namespace: "{{ poolboy_test_namespace }}" + register: r_get_resource_claim + failed_when: >- + r_get_resource_claim.resources[0].status.ready != true or + r_get_resource_claim.resources[0].status.lifespan.firstReady is undefined or + ( + r_get_resource_claim.resources[0].status.lifespan.end | to_datetime("%Y-%m-%dT%H:%M:%SZ") - + r_get_resource_claim.resources[0].status.lifespan.firstReady | to_datetime("%Y-%m-%dT%H:%M:%SZ") + ).total_seconds() != 24 * 60 * 60 + until: r_get_resource_claim is success + delay: 1 + retries: 10 + +- name: Delete ResourceClaim test-ready-01 + kubernetes.core.k8s: + api_version: "{{ poolboy_domain }}/v1" + kind: ResourceClaim + name: test-ready-01 + namespace: "{{ poolboy_test_namespace }}" + state: absent + +- name: Verify delete of ResourceClaim test-ready-01 + kubernetes.core.k8s_info: + api_version: "{{ poolboy_domain }}/v1" + kind: ResourceClaim + name: test-ready-01 + namespace: "{{ poolboy_test_namespace }}" + register: r_get_resource_claim + failed_when: r_get_resource_claim.resources | length != 0 + until: r_get_resource_claim is success + retries: 5 + delay: 1 +... diff --git a/test/roles/poolboy_test_simple/tasks/test.yaml b/test/roles/poolboy_test_simple/tasks/test.yaml index 5fdd468..e90cbb6 100644 --- a/test/roles/poolboy_test_simple/tasks/test.yaml +++ b/test/roles/poolboy_test_simple/tasks/test.yaml @@ -14,6 +14,7 @@ - test-pool-02.yaml - test-pool-03.yaml - test-pool-04.yaml + - test-ready-01.yaml - test-recreate-01.yaml - test-vars-01.yaml - test-vars-02.yaml