From 1ad2210a99c97b58a6ea94859c073604eac1fd3b Mon Sep 17 00:00:00 2001 From: Andrew Jones Date: Tue, 19 May 2026 15:22:00 +1000 Subject: [PATCH 1/8] feat(ocp_console_embed): add destroy action using core_workloads dispatch pattern --- .../tasks/main.yml | 244 +----------------- .../tasks/remove_workload.yml | 80 ++++++ .../tasks/workload.yml | 240 +++++++++++++++++ 3 files changed, 326 insertions(+), 238 deletions(-) create mode 100644 roles/ocp4_workload_ocp_console_embed/tasks/remove_workload.yml create mode 100644 roles/ocp4_workload_ocp_console_embed/tasks/workload.yml diff --git a/roles/ocp4_workload_ocp_console_embed/tasks/main.yml b/roles/ocp4_workload_ocp_console_embed/tasks/main.yml index d4c62d0..4999343 100644 --- a/roles/ocp4_workload_ocp_console_embed/tasks/main.yml +++ b/roles/ocp4_workload_ocp_console_embed/tasks/main.yml @@ -1,240 +1,8 @@ --- -- name: Set ocp_console_embed_domain fallback - ansible.builtin.set_fact: - ocp_console_embed_domain: >- - {{ (ocp_console_embed_domain - | default(openshift_cluster_ingress_domain - | default(sandbox_openshift_apps_domain, true) - | default(showroom_openshift_apps_domain, true) - | default('', true), true)) | trim }} +- name: Running workload provision tasks + when: ACTION == "provision" + ansible.builtin.include_tasks: workload.yml -- name: Fail if ocp_console_embed_domain is not set - ansible.builtin.fail: - msg: >- - ocp_console_embed_domain is empty. Set ocp_console_embed_domain, - openshift_cluster_ingress_domain, sandbox_openshift_apps_domain, - or showroom_openshift_apps_domain. - when: ocp_console_embed_domain | length == 0 - -- name: Ensure namespace exists - kubernetes.core.k8s: - api_version: v1 - kind: Namespace - name: "{{ ocp_console_embed_namespace }}" - state: present - -# Deploy resources that trigger async service CA operator provisioning: -# - Service annotation -> TLS Secret -# - ConfigMap annotation -> CA bundle injection -- name: Deploy RBAC for route reconciliation - kubernetes.core.k8s: - state: present - definition: "{{ __ocp_console_embed_rbac }}" - loop: "{{ lookup('template', 'rbac.yaml.j2') | from_yaml_all | list }}" - loop_control: - loop_var: __ocp_console_embed_rbac - -- name: Deploy webhook pre-requisite resources - kubernetes.core.k8s: - state: present - definition: "{{ lookup('template', __ocp_console_embed_template) }}" - namespace: "{{ ocp_console_embed_namespace }}" - loop: - - serviceaccount.yaml.j2 - - webhook-script.yaml.j2 - - webhook-cabundle.yaml.j2 - - webhook-service.yaml.j2 - loop_control: - loop_var: __ocp_console_embed_template - -- name: Wait for TLS Secret to be provisioned by service CA operator - kubernetes.core.k8s_info: - api_version: v1 - kind: Secret - name: "{{ ocp_console_embed_name }}-webhook-tls" - namespace: "{{ ocp_console_embed_namespace }}" - register: r_tls_secret - until: - - r_tls_secret.resources | length > 0 - - r_tls_secret.resources[0].data['tls.crt'] is defined - - r_tls_secret.resources[0].data['tls.crt'] | length > 0 - retries: "{{ ocp_console_embed_service_ca_wait_retries }}" - delay: "{{ ocp_console_embed_service_ca_wait_delay }}" - -- name: Wait for service CA bundle to be injected into ConfigMap - kubernetes.core.k8s_info: - api_version: v1 - kind: ConfigMap - name: "{{ ocp_console_embed_name }}-service-ca" - namespace: "{{ ocp_console_embed_namespace }}" - register: r_ca_bundle - until: - - r_ca_bundle.resources | length > 0 - - r_ca_bundle.resources[0].data['service-ca.crt'] is defined - - r_ca_bundle.resources[0].data['service-ca.crt'] | length > 0 - retries: "{{ ocp_console_embed_service_ca_wait_retries }}" - delay: "{{ ocp_console_embed_service_ca_wait_delay }}" - -- name: Deploy webhook Deployment - kubernetes.core.k8s: - state: present - definition: "{{ lookup('template', 'webhook-deployment.yaml.j2') }}" - namespace: "{{ ocp_console_embed_namespace }}" - -- name: Wait for webhook Deployment to be ready - kubernetes.core.k8s_info: - api_version: apps/v1 - kind: Deployment - name: "{{ ocp_console_embed_name }}-webhook" - namespace: "{{ ocp_console_embed_namespace }}" - register: r_webhook_deploy - until: - - r_webhook_deploy.resources | default([]) | length > 0 - - r_webhook_deploy.resources[0].status.readyReplicas is defined - - r_webhook_deploy.resources[0].status.readyReplicas >= 1 - retries: "{{ ocp_console_embed_webhook_wait_retries }}" - delay: "{{ ocp_console_embed_webhook_wait_delay }}" - -# Register the webhook only after the pod is ready to serve requests. -- name: Deploy MutatingWebhookConfiguration - kubernetes.core.k8s: - state: present - definition: "{{ lookup('template', 'webhook-config.yaml.j2') }}" - -- name: Wait for caBundle injection in MutatingWebhookConfiguration - kubernetes.core.k8s_info: - api_version: admissionregistration.k8s.io/v1 - kind: MutatingWebhookConfiguration - name: "{{ ocp_console_embed_name }}-oauth-route" - register: r_webhook_config - until: - - r_webhook_config.resources | length > 0 - - r_webhook_config.resources[0].webhooks[0].clientConfig.caBundle is defined - - r_webhook_config.resources[0].webhooks[0].clientConfig.caBundle | length > 0 - retries: "{{ ocp_console_embed_service_ca_wait_retries }}" - delay: "{{ ocp_console_embed_service_ca_wait_delay }}" - -- name: Get current router Deployment - kubernetes.core.k8s_info: - api_version: apps/v1 - kind: Deployment - name: router-default - namespace: openshift-ingress - register: r_router_pre - -- name: Get worker nodes - kubernetes.core.k8s_info: - api_version: v1 - kind: Node - label_selectors: - - node-role.kubernetes.io/worker - register: r_worker_nodes - -# On clusters where the control plane has been cordoned (e.g. SNO scaled -# with worker VMs), the ingress operator may have set more router replicas -# than there are schedulable nodes. Because the router uses hostNetwork, -# only one pod can run per node. A rolling update with maxSurge=0 will -# deadlock when it cannot schedule the replacement pod. -- name: Count schedulable worker nodes - vars: - _total: "{{ r_worker_nodes.resources | length | int }}" - _cordoned: >- - {{ r_worker_nodes.resources - | map(attribute='spec') - | selectattr('unschedulable', 'defined') - | selectattr('unschedulable', 'equalto', true) - | list | length }} - ansible.builtin.set_fact: - _ocp_console_embed_schedulable_workers: "{{ (_total | int) - (_cordoned | int) }}" - -- name: Scale IngressController replicas to match schedulable nodes - kubernetes.core.k8s: - api_version: operator.openshift.io/v1 - kind: IngressController - name: default - namespace: openshift-ingress-operator - state: present - definition: - spec: - replicas: "{{ _ocp_console_embed_schedulable_workers | int }}" - when: - - r_router_pre.resources | length > 0 - - (_ocp_console_embed_schedulable_workers | int) > 0 - - (r_router_pre.resources[0].spec.replicas | default(0) | int) - > (_ocp_console_embed_schedulable_workers | int) - -- name: Wait for router to stabilize after replica adjustment - ansible.builtin.include_tasks: wait-router-rollout.yml - vars: - _ocp_console_embed_wait_reason: replica adjustment - when: - - r_router_pre.resources | length > 0 - - (_ocp_console_embed_schedulable_workers | int) > 0 - - (r_router_pre.resources[0].spec.replicas | default(0) | int) - > (_ocp_console_embed_schedulable_workers | int) - -- name: Patch IngressController to remove X-Frame-Options and set CSP - kubernetes.core.k8s: - api_version: operator.openshift.io/v1 - kind: IngressController - name: default - namespace: openshift-ingress-operator - state: present - definition: - spec: - httpHeaders: - actions: - response: - - name: X-Frame-Options - action: - type: Delete - - name: Content-Security-Policy - action: - type: Set - set: - value: "frame-ancestors 'self' https://*.{{ ocp_console_embed_domain }}" - -- name: Wait for router rollout to complete - ansible.builtin.include_tasks: wait-router-rollout.yml - vars: - _ocp_console_embed_wait_reason: IngressController patch - -- name: Wait for Service CA certificate in openshift-authentication - kubernetes.core.k8s_info: - api_version: v1 - kind: ConfigMap - name: v4-0-config-system-service-ca - namespace: openshift-authentication - register: r_service_ca - until: - - r_service_ca.resources | length > 0 - - r_service_ca.resources[0].data['service-ca.crt'] is defined - retries: "{{ ocp_console_embed_service_ca_wait_retries }}" - delay: "{{ ocp_console_embed_service_ca_wait_delay }}" - -- name: Patch OAuth Route to use reencrypt TLS - kubernetes.core.k8s: - api_version: route.openshift.io/v1 - kind: Route - name: oauth-openshift - namespace: openshift-authentication - state: present - definition: - spec: - tls: - termination: reencrypt - insecureEdgeTerminationPolicy: Redirect - destinationCACertificate: "{{ r_service_ca.resources[0].data['service-ca.crt'] }}" - -- name: Verify OAuth route stays reencrypt after auth operator reconciliation - kubernetes.core.k8s_info: - api_version: route.openshift.io/v1 - kind: Route - name: oauth-openshift - namespace: openshift-authentication - register: r_oauth_route - until: - - r_oauth_route.resources | length > 0 - - r_oauth_route.resources[0].spec.tls.termination == 'reencrypt' - retries: "{{ ocp_console_embed_verify_retries }}" - delay: "{{ ocp_console_embed_verify_delay }}" +- name: Running workload removal tasks + when: ACTION == "destroy" + ansible.builtin.include_tasks: remove_workload.yml diff --git a/roles/ocp4_workload_ocp_console_embed/tasks/remove_workload.yml b/roles/ocp4_workload_ocp_console_embed/tasks/remove_workload.yml new file mode 100644 index 0000000..a129513 --- /dev/null +++ b/roles/ocp4_workload_ocp_console_embed/tasks/remove_workload.yml @@ -0,0 +1,80 @@ +--- +# Remove the MutatingWebhookConfiguration first so the webhook stops +# intercepting route writes before we tear down the serving infrastructure. +- name: Remove MutatingWebhookConfiguration + kubernetes.core.k8s: + state: absent + api_version: admissionregistration.k8s.io/v1 + kind: MutatingWebhookConfiguration + name: "{{ ocp_console_embed_name }}-oauth-route" + register: r_remove_webhook + failed_when: false + +# Revert the IngressController to default header behaviour. +# Removing httpHeaders.actions lets the ingress operator restore its +# defaults (X-Frame-Options: DENY, no custom CSP). +- name: Reset IngressController httpHeaders + kubernetes.core.k8s: + api_version: operator.openshift.io/v1 + kind: IngressController + name: default + namespace: openshift-ingress-operator + state: present + definition: + spec: + httpHeaders: + actions: + response: [] + +- name: Wait for router rollout to complete + ansible.builtin.include_tasks: wait-router-rollout.yml + vars: + _ocp_console_embed_wait_reason: IngressController header reset + +# Remove the RBAC resources from openshift-authentication (cluster-scoped +# within that namespace, not cleaned up by namespace deletion below). +- name: Remove RBAC for route reconciliation + kubernetes.core.k8s: + state: absent + definition: "{{ __ocp_console_embed_rbac }}" + loop: "{{ lookup('template', 'rbac.yaml.j2') | from_yaml_all | list }}" + loop_control: + loop_var: __ocp_console_embed_rbac + register: r_remove_rbac + failed_when: false + +# Deleting the namespace removes the Deployment, Service, ServiceAccount, +# ConfigMaps, Secrets, and all other namespaced resources. +- name: Remove namespace + kubernetes.core.k8s: + state: absent + api_version: v1 + kind: Namespace + name: "{{ ocp_console_embed_namespace }}" + +- name: Wait for namespace to be fully removed + kubernetes.core.k8s_info: + api_version: v1 + kind: Namespace + name: "{{ ocp_console_embed_namespace }}" + register: r_ns + retries: 30 + delay: 10 + until: r_ns.resources | length == 0 + +# With the webhook gone the authentication operator will reconcile the +# OAuth route back to passthrough on its own. Wait for that to happen +# so the caller knows the cluster is fully restored. +- name: Wait for OAuth route to revert to passthrough + kubernetes.core.k8s_info: + api_version: route.openshift.io/v1 + kind: Route + name: oauth-openshift + namespace: openshift-authentication + register: r_oauth_route + retries: "{{ ocp_console_embed_verify_retries }}" + delay: "{{ ocp_console_embed_verify_delay }}" + until: + - r_oauth_route.resources | length > 0 + - r_oauth_route.resources[0].spec.tls.termination == 'passthrough' + failed_when: false diff --git a/roles/ocp4_workload_ocp_console_embed/tasks/workload.yml b/roles/ocp4_workload_ocp_console_embed/tasks/workload.yml new file mode 100644 index 0000000..d4c62d0 --- /dev/null +++ b/roles/ocp4_workload_ocp_console_embed/tasks/workload.yml @@ -0,0 +1,240 @@ +--- +- name: Set ocp_console_embed_domain fallback + ansible.builtin.set_fact: + ocp_console_embed_domain: >- + {{ (ocp_console_embed_domain + | default(openshift_cluster_ingress_domain + | default(sandbox_openshift_apps_domain, true) + | default(showroom_openshift_apps_domain, true) + | default('', true), true)) | trim }} + +- name: Fail if ocp_console_embed_domain is not set + ansible.builtin.fail: + msg: >- + ocp_console_embed_domain is empty. Set ocp_console_embed_domain, + openshift_cluster_ingress_domain, sandbox_openshift_apps_domain, + or showroom_openshift_apps_domain. + when: ocp_console_embed_domain | length == 0 + +- name: Ensure namespace exists + kubernetes.core.k8s: + api_version: v1 + kind: Namespace + name: "{{ ocp_console_embed_namespace }}" + state: present + +# Deploy resources that trigger async service CA operator provisioning: +# - Service annotation -> TLS Secret +# - ConfigMap annotation -> CA bundle injection +- name: Deploy RBAC for route reconciliation + kubernetes.core.k8s: + state: present + definition: "{{ __ocp_console_embed_rbac }}" + loop: "{{ lookup('template', 'rbac.yaml.j2') | from_yaml_all | list }}" + loop_control: + loop_var: __ocp_console_embed_rbac + +- name: Deploy webhook pre-requisite resources + kubernetes.core.k8s: + state: present + definition: "{{ lookup('template', __ocp_console_embed_template) }}" + namespace: "{{ ocp_console_embed_namespace }}" + loop: + - serviceaccount.yaml.j2 + - webhook-script.yaml.j2 + - webhook-cabundle.yaml.j2 + - webhook-service.yaml.j2 + loop_control: + loop_var: __ocp_console_embed_template + +- name: Wait for TLS Secret to be provisioned by service CA operator + kubernetes.core.k8s_info: + api_version: v1 + kind: Secret + name: "{{ ocp_console_embed_name }}-webhook-tls" + namespace: "{{ ocp_console_embed_namespace }}" + register: r_tls_secret + until: + - r_tls_secret.resources | length > 0 + - r_tls_secret.resources[0].data['tls.crt'] is defined + - r_tls_secret.resources[0].data['tls.crt'] | length > 0 + retries: "{{ ocp_console_embed_service_ca_wait_retries }}" + delay: "{{ ocp_console_embed_service_ca_wait_delay }}" + +- name: Wait for service CA bundle to be injected into ConfigMap + kubernetes.core.k8s_info: + api_version: v1 + kind: ConfigMap + name: "{{ ocp_console_embed_name }}-service-ca" + namespace: "{{ ocp_console_embed_namespace }}" + register: r_ca_bundle + until: + - r_ca_bundle.resources | length > 0 + - r_ca_bundle.resources[0].data['service-ca.crt'] is defined + - r_ca_bundle.resources[0].data['service-ca.crt'] | length > 0 + retries: "{{ ocp_console_embed_service_ca_wait_retries }}" + delay: "{{ ocp_console_embed_service_ca_wait_delay }}" + +- name: Deploy webhook Deployment + kubernetes.core.k8s: + state: present + definition: "{{ lookup('template', 'webhook-deployment.yaml.j2') }}" + namespace: "{{ ocp_console_embed_namespace }}" + +- name: Wait for webhook Deployment to be ready + kubernetes.core.k8s_info: + api_version: apps/v1 + kind: Deployment + name: "{{ ocp_console_embed_name }}-webhook" + namespace: "{{ ocp_console_embed_namespace }}" + register: r_webhook_deploy + until: + - r_webhook_deploy.resources | default([]) | length > 0 + - r_webhook_deploy.resources[0].status.readyReplicas is defined + - r_webhook_deploy.resources[0].status.readyReplicas >= 1 + retries: "{{ ocp_console_embed_webhook_wait_retries }}" + delay: "{{ ocp_console_embed_webhook_wait_delay }}" + +# Register the webhook only after the pod is ready to serve requests. +- name: Deploy MutatingWebhookConfiguration + kubernetes.core.k8s: + state: present + definition: "{{ lookup('template', 'webhook-config.yaml.j2') }}" + +- name: Wait for caBundle injection in MutatingWebhookConfiguration + kubernetes.core.k8s_info: + api_version: admissionregistration.k8s.io/v1 + kind: MutatingWebhookConfiguration + name: "{{ ocp_console_embed_name }}-oauth-route" + register: r_webhook_config + until: + - r_webhook_config.resources | length > 0 + - r_webhook_config.resources[0].webhooks[0].clientConfig.caBundle is defined + - r_webhook_config.resources[0].webhooks[0].clientConfig.caBundle | length > 0 + retries: "{{ ocp_console_embed_service_ca_wait_retries }}" + delay: "{{ ocp_console_embed_service_ca_wait_delay }}" + +- name: Get current router Deployment + kubernetes.core.k8s_info: + api_version: apps/v1 + kind: Deployment + name: router-default + namespace: openshift-ingress + register: r_router_pre + +- name: Get worker nodes + kubernetes.core.k8s_info: + api_version: v1 + kind: Node + label_selectors: + - node-role.kubernetes.io/worker + register: r_worker_nodes + +# On clusters where the control plane has been cordoned (e.g. SNO scaled +# with worker VMs), the ingress operator may have set more router replicas +# than there are schedulable nodes. Because the router uses hostNetwork, +# only one pod can run per node. A rolling update with maxSurge=0 will +# deadlock when it cannot schedule the replacement pod. +- name: Count schedulable worker nodes + vars: + _total: "{{ r_worker_nodes.resources | length | int }}" + _cordoned: >- + {{ r_worker_nodes.resources + | map(attribute='spec') + | selectattr('unschedulable', 'defined') + | selectattr('unschedulable', 'equalto', true) + | list | length }} + ansible.builtin.set_fact: + _ocp_console_embed_schedulable_workers: "{{ (_total | int) - (_cordoned | int) }}" + +- name: Scale IngressController replicas to match schedulable nodes + kubernetes.core.k8s: + api_version: operator.openshift.io/v1 + kind: IngressController + name: default + namespace: openshift-ingress-operator + state: present + definition: + spec: + replicas: "{{ _ocp_console_embed_schedulable_workers | int }}" + when: + - r_router_pre.resources | length > 0 + - (_ocp_console_embed_schedulable_workers | int) > 0 + - (r_router_pre.resources[0].spec.replicas | default(0) | int) + > (_ocp_console_embed_schedulable_workers | int) + +- name: Wait for router to stabilize after replica adjustment + ansible.builtin.include_tasks: wait-router-rollout.yml + vars: + _ocp_console_embed_wait_reason: replica adjustment + when: + - r_router_pre.resources | length > 0 + - (_ocp_console_embed_schedulable_workers | int) > 0 + - (r_router_pre.resources[0].spec.replicas | default(0) | int) + > (_ocp_console_embed_schedulable_workers | int) + +- name: Patch IngressController to remove X-Frame-Options and set CSP + kubernetes.core.k8s: + api_version: operator.openshift.io/v1 + kind: IngressController + name: default + namespace: openshift-ingress-operator + state: present + definition: + spec: + httpHeaders: + actions: + response: + - name: X-Frame-Options + action: + type: Delete + - name: Content-Security-Policy + action: + type: Set + set: + value: "frame-ancestors 'self' https://*.{{ ocp_console_embed_domain }}" + +- name: Wait for router rollout to complete + ansible.builtin.include_tasks: wait-router-rollout.yml + vars: + _ocp_console_embed_wait_reason: IngressController patch + +- name: Wait for Service CA certificate in openshift-authentication + kubernetes.core.k8s_info: + api_version: v1 + kind: ConfigMap + name: v4-0-config-system-service-ca + namespace: openshift-authentication + register: r_service_ca + until: + - r_service_ca.resources | length > 0 + - r_service_ca.resources[0].data['service-ca.crt'] is defined + retries: "{{ ocp_console_embed_service_ca_wait_retries }}" + delay: "{{ ocp_console_embed_service_ca_wait_delay }}" + +- name: Patch OAuth Route to use reencrypt TLS + kubernetes.core.k8s: + api_version: route.openshift.io/v1 + kind: Route + name: oauth-openshift + namespace: openshift-authentication + state: present + definition: + spec: + tls: + termination: reencrypt + insecureEdgeTerminationPolicy: Redirect + destinationCACertificate: "{{ r_service_ca.resources[0].data['service-ca.crt'] }}" + +- name: Verify OAuth route stays reencrypt after auth operator reconciliation + kubernetes.core.k8s_info: + api_version: route.openshift.io/v1 + kind: Route + name: oauth-openshift + namespace: openshift-authentication + register: r_oauth_route + until: + - r_oauth_route.resources | length > 0 + - r_oauth_route.resources[0].spec.tls.termination == 'reencrypt' + retries: "{{ ocp_console_embed_verify_retries }}" + delay: "{{ ocp_console_embed_verify_delay }}" From df705e1bd172a200d2665b6d8cd9b8249319b25a Mon Sep 17 00:00:00 2001 From: Andrew Jones Date: Wed, 20 May 2026 11:23:08 +1000 Subject: [PATCH 2/8] test(ocp_console_embed): add HTTP handler tests for do_GET and do_POST Cover the healthz endpoint (200/503), unknown paths (404), valid admission requests, oversized body rejection (413), malformed JSON fallback to allow, missing UID handling, and exception-in-handler fail-open behavior. Co-authored-by: Cursor --- .../tests/test_webhook.py | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/roles/ocp4_workload_ocp_console_embed/tests/test_webhook.py b/roles/ocp4_workload_ocp_console_embed/tests/test_webhook.py index d0b4026..9d1b293 100644 --- a/roles/ocp4_workload_ocp_console_embed/tests/test_webhook.py +++ b/roles/ocp4_workload_ocp_console_embed/tests/test_webhook.py @@ -563,6 +563,158 @@ def test_stops_on_event(self, mock_once, mock_watch): mock_watch.assert_not_called() +class TestDoGET(unittest.TestCase): + """Tests for the do_GET HTTP handler (healthz + 404).""" + + def _make_handler(self): + handler = webhook.Handler.__new__(webhook.Handler) + handler.path = '/healthz' + handler.wfile = io.BytesIO() + handler._headers_buffer = [] + handler.request_version = 'HTTP/1.1' + handler.responses = webhook.BaseHTTPRequestHandler.responses + return handler + + def test_healthz_returns_200_when_service_ca_loaded(self): + saved = webhook.SERVICE_CA + try: + webhook.SERVICE_CA = "fake-ca" + handler = self._make_handler() + handler.send_response = MagicMock() + handler.send_header = MagicMock() + handler.end_headers = MagicMock() + handler.do_GET() + handler.send_response.assert_called_with(200) + self.assertEqual(handler.wfile.getvalue(), b'ok') + finally: + webhook.SERVICE_CA = saved + + def test_healthz_returns_503_when_no_service_ca(self): + saved = webhook.SERVICE_CA + try: + webhook.SERVICE_CA = None + handler = self._make_handler() + handler.send_response = MagicMock() + handler.send_header = MagicMock() + handler.end_headers = MagicMock() + handler.do_GET() + handler.send_response.assert_called_with(503) + self.assertIn(b'service CA not loaded', handler.wfile.getvalue()) + finally: + webhook.SERVICE_CA = saved + + def test_unknown_path_returns_404(self): + saved = webhook.SERVICE_CA + try: + webhook.SERVICE_CA = "fake-ca" + handler = self._make_handler() + handler.path = '/unknown' + handler.send_response = MagicMock() + handler.send_header = MagicMock() + handler.end_headers = MagicMock() + handler.do_GET() + handler.send_response.assert_called_with(404) + finally: + webhook.SERVICE_CA = saved + + +class TestDoPOST(unittest.TestCase): + """Tests for the do_POST HTTP handler.""" + + def _make_handler(self, body_bytes, content_length=None): + handler = webhook.Handler.__new__(webhook.Handler) + handler.path = '/mutate' + handler.rfile = io.BytesIO(body_bytes) + handler.wfile = io.BytesIO() + handler._headers_buffer = [] + handler.request_version = 'HTTP/1.1' + handler.responses = webhook.BaseHTTPRequestHandler.responses + handler.headers = { + 'Content-Length': str(content_length if content_length is not None else len(body_bytes)), + } + handler.send_response = MagicMock() + handler.send_header = MagicMock() + handler.end_headers = MagicMock() + return handler + + def test_valid_admission_request(self): + saved = webhook.SERVICE_CA + try: + webhook.SERVICE_CA = "fake-ca" + body = json.dumps({ + 'request': { + 'uid': 'test-uid', + 'object': { + 'metadata': {'name': 'other-route', 'namespace': 'default'}, + 'spec': {'tls': {'termination': 'edge'}}, + } + } + }).encode() + handler = self._make_handler(body) + handler.do_POST() + handler.send_response.assert_called_with(200) + output = json.loads(handler.wfile.getvalue().decode()) + self.assertTrue(output['response']['allowed']) + self.assertEqual(output['response']['uid'], 'test-uid') + finally: + webhook.SERVICE_CA = saved + + def test_rejects_oversized_body(self): + handler = self._make_handler(b'', content_length=webhook.MAX_BODY_SIZE + 1) + handler.do_POST() + handler.send_response.assert_called_with(413) + self.assertEqual(handler.wfile.getvalue(), b'') + + def test_malformed_json_returns_allow(self): + saved = webhook.SERVICE_CA + try: + webhook.SERVICE_CA = "fake-ca" + handler = self._make_handler(b'not-json') + handler.do_POST() + handler.send_response.assert_called_with(200) + output = json.loads(handler.wfile.getvalue().decode()) + self.assertTrue(output['response']['allowed']) + self.assertEqual(output['response']['uid'], 'unknown') + finally: + webhook.SERVICE_CA = saved + + def test_missing_uid_returns_allow(self): + saved = webhook.SERVICE_CA + try: + webhook.SERVICE_CA = "fake-ca" + body = json.dumps({'request': {}}).encode() + handler = self._make_handler(body) + handler.do_POST() + handler.send_response.assert_called_with(200) + output = json.loads(handler.wfile.getvalue().decode()) + self.assertTrue(output['response']['allowed']) + finally: + webhook.SERVICE_CA = saved + + def test_exception_in_handle_review_returns_allow(self): + saved = webhook.SERVICE_CA + try: + webhook.SERVICE_CA = "fake-ca" + body = json.dumps({ + 'request': { + 'uid': 'err-uid', + 'object': { + 'metadata': {'name': 'oauth-openshift', 'namespace': 'openshift-authentication'}, + 'spec': {'tls': {'termination': 'passthrough'}}, + } + } + }).encode() + handler = self._make_handler(body) + handler.handle_review = MagicMock(side_effect=RuntimeError("boom")) + handler.do_POST() + handler.send_response.assert_called_with(200) + output = json.loads(handler.wfile.getvalue().decode()) + self.assertTrue(output['response']['allowed']) + self.assertEqual(output['response']['uid'], 'err-uid') + finally: + webhook.SERVICE_CA = saved + + def urllib_error_stub(): import urllib.error return urllib.error.URLError("connection refused") From f269b810bc28f7685609620affc7d41512719e30 Mon Sep 17 00:00:00 2001 From: Andrew Jones Date: Wed, 20 May 2026 11:31:13 +1000 Subject: [PATCH 3/8] docs(ocp_console_embed): document all role variables and prerequisites Add all 16 configurable variables from defaults/main.yml to the README, organized into required and optional sections. Add prerequisites (OCP 4.x, kubernetes.core collection) and note that the IngressController patch affects all default-ingress routes. Co-authored-by: Cursor --- .../README.adoc | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/roles/ocp4_workload_ocp_console_embed/README.adoc b/roles/ocp4_workload_ocp_console_embed/README.adoc index 0bf1347..a68413a 100644 --- a/roles/ocp4_workload_ocp_console_embed/README.adoc +++ b/roles/ocp4_workload_ocp_console_embed/README.adoc @@ -4,21 +4,58 @@ This role deploys the OCP Console Embed workload, which configures the OpenShift == Variables +=== Required + [cols="1,1,2",options="header"] |=== | Variable | Default | Description +| `ocp_console_embed_domain` | `""` | Base domain of the OpenShift cluster (e.g. `apps.cluster.example.com`). Falls back to `openshift_cluster_ingress_domain`, `sandbox_openshift_apps_domain`, or `showroom_openshift_apps_domain`. +| `ACTION` | _(none)_ | Must be `provision` or `destroy`. Follows the agnosticD workload dispatch convention. +|=== + +=== Optional + +[cols="1,1,2",options="header"] +|=== +| Variable | Default | Description + | `ocp_console_embed_namespace` | `ocp-console-embed` | Namespace to deploy resources into. -| `ocp_console_embed_name` | `ocp-console-embed` | Base name for resources. -| `ocp_console_embed_image` | `registry.access.redhat.com/ubi10/python-312-minimal:10.1` | Image to use for the webhook. -| `ocp_console_embed_domain` | `""` | Base domain of the OpenShift cluster. Falls back to `openshift_cluster_ingress_domain`, `sandbox_openshift_apps_domain`, or `showroom_openshift_apps_domain`. +| `ocp_console_embed_name` | `ocp-console-embed` | Base name for all created resources. +| `ocp_console_embed_image` | `registry.access.redhat.com/ubi10/python-312-minimal:10.1` | Container image for the webhook pod. + +| `ocp_console_embed_service_ca_wait_retries` | `30` | Retry count when waiting for the service CA operator to provision a TLS Secret or inject the CA bundle. +| `ocp_console_embed_service_ca_wait_delay` | `5` | Delay (seconds) between retries for service CA provisioning. + +| `ocp_console_embed_webhook_wait_retries` | `60` | Retry count when waiting for the webhook Deployment pod to become ready. +| `ocp_console_embed_webhook_wait_delay` | `5` | Delay (seconds) between retries for webhook readiness. + +| `ocp_console_embed_watch_timeout` | `300` | Server-side timeout (seconds) for the Kubernetes watch on the oauth-openshift route. The reconciler reconnects automatically when this expires. + +| `ocp_console_embed_router_wait_retries` | `120` | Retry count when waiting for the router rollout after IngressController changes. +| `ocp_console_embed_router_wait_delay` | `10` | Delay (seconds) between retries for router rollout. Large clusters with 10+ replicas may need the full window. + +| `ocp_console_embed_verify_retries` | `12` | Retry count when verifying the OAuth route stays reencrypt after auth operator reconciliation. +| `ocp_console_embed_verify_delay` | `5` | Delay (seconds) between verification retries. + +| `ocp_console_embed_webhook_readiness_initial_delay` | `3` | Initial delay (seconds) for the webhook readiness probe. +| `ocp_console_embed_webhook_readiness_period` | `10` | Period (seconds) for the webhook readiness probe. +| `ocp_console_embed_webhook_liveness_initial_delay` | `5` | Initial delay (seconds) for the webhook liveness probe. +| `ocp_console_embed_webhook_liveness_period` | `15` | Period (seconds) for the webhook liveness probe. |=== +== Prerequisites + +- OpenShift 4.x with the service CA operator enabled (default on all standard installations). +- Ansible >= 2.14 with the `kubernetes.core` collection installed. + == Description The role deploys: + - A MutatingWebhook that intercepts Route updates to `oauth-openshift` and forces `reencrypt` TLS. -- Patches the default IngressController to strip `X-Frame-Options` and set `Content-Security-Policy`. +- Patches the *default* IngressController to strip `X-Frame-Options` and set `Content-Security-Policy` (this affects all routes served by the default ingress, not just OAuth). - Patches the `oauth-openshift` Route to use `reencrypt` TLS immediately (maintained by the webhook). +- On clusters where router replicas exceed schedulable worker nodes, scales the IngressController down to prevent rollout deadlocks (reverted on destroy). == Testing From 76e035841b63d995be0f12d0b88efde599380512 Mon Sep 17 00:00:00 2001 From: Andrew Jones Date: Mon, 3 Aug 2026 16:10:45 +1000 Subject: [PATCH 4/8] fix(vm_workload_showroom, user_data_seed): add ACTION dispatch with destroy no-op Both roles ran their full provision flow unconditionally, including when called with ACTION == "destroy". This caused failures that blocked cluster teardown in dedicated cluster CIs. Wrap provision tasks in ACTION | default("provision") == "provision" and add a debug no-op for destroy. The default("provision") preserves backward compatibility for callers that don't set ACTION. Ref: GPTEINFRA-16638 Co-authored-by: Cursor --- .../tasks/main.yml | 35 ++++--- roles/vm_workload_showroom/tasks/main.yml | 97 +++++++++++-------- 2 files changed, 77 insertions(+), 55 deletions(-) diff --git a/roles/ocp4_workload_showroom_user_data_seed/tasks/main.yml b/roles/ocp4_workload_showroom_user_data_seed/tasks/main.yml index 9e654d4..5acbd30 100644 --- a/roles/ocp4_workload_showroom_user_data_seed/tasks/main.yml +++ b/roles/ocp4_workload_showroom_user_data_seed/tasks/main.yml @@ -1,14 +1,25 @@ --- -- name: Validate seed data is defined and is a mapping - ansible.builtin.assert: - that: - - ocp4_workload_showroom_user_data_seed is defined - - ocp4_workload_showroom_user_data_seed is mapping - quiet: true - fail_msg: >- - Expected ocp4_workload_showroom_user_data_seed to be defined as a mapping. - -- name: Seed AgnosticD user_data for Showroom - agnosticd.core.agnosticd_user_info: - data: "{{ ocp4_workload_showroom_user_data_seed }}" +- name: Running workload provision tasks + when: ACTION | default("provision") == "provision" + block: + + - name: Validate seed data is defined and is a mapping + ansible.builtin.assert: + that: + - ocp4_workload_showroom_user_data_seed is defined + - ocp4_workload_showroom_user_data_seed is mapping + quiet: true + fail_msg: >- + Expected ocp4_workload_showroom_user_data_seed to be defined as a mapping. + + - name: Seed AgnosticD user_data for Showroom + agnosticd.core.agnosticd_user_info: + data: "{{ ocp4_workload_showroom_user_data_seed }}" + +- name: Running workload removal tasks + when: ACTION | default("provision") == "destroy" + ansible.builtin.debug: + msg: >- + ocp4_workload_showroom_user_data_seed destroy is a no-op. + User data does not require cleanup. diff --git a/roles/vm_workload_showroom/tasks/main.yml b/roles/vm_workload_showroom/tasks/main.yml index b369258..d193e4d 100644 --- a/roles/vm_workload_showroom/tasks/main.yml +++ b/roles/vm_workload_showroom/tasks/main.yml @@ -1,47 +1,58 @@ --- -- name: Deploy the showroom user interface - when: showroom_deploy | default(true) | bool +- name: Running workload provision tasks + when: ACTION | default("provision") == "provision" block: - - name: Setup OS dependencies, packages - ansible.builtin.include_tasks: - file: 10-showroom-dependencies.yml - tags: - - showroom-dependency-setup - - - name: Setup the showroom user and working directories - ansible.builtin.include_tasks: - file: 20-showroom-user-setup.yml - tags: - - showroom-user-setup - - - name: Setup ssh config and other security measures for lab accounts - ansible.builtin.include_tasks: - file: 22-showroom-users-security.yml - tags: - - showroom-user-security - - - name: Clone primary showroom repo and inject externals (vars, html templates) - ansible.builtin.include_tasks: - file: 30-showroom-clone-and-inject.yml - tags: - - showroom-clone-and-inject - - - name: Render antora content - ansible.builtin.include_tasks: - file: 40-showroom-render.yml - tags: - - showroom-render - - - name: Create, enable, start showroom systemd service - ansible.builtin.include_tasks: - file: 50-showroom-service.yml - tags: - - showroom-services - - - name: Validate showroom service and output view url(s) - ansible.builtin.include_tasks: - file: 60-showroom-verify.yml - tags: - - showroom-verify + - name: Deploy the showroom user interface + when: showroom_deploy | default(true) | bool + block: + + - name: Setup OS dependencies, packages + ansible.builtin.include_tasks: + file: 10-showroom-dependencies.yml + tags: + - showroom-dependency-setup + + - name: Setup the showroom user and working directories + ansible.builtin.include_tasks: + file: 20-showroom-user-setup.yml + tags: + - showroom-user-setup + + - name: Setup ssh config and other security measures for lab accounts + ansible.builtin.include_tasks: + file: 22-showroom-users-security.yml + tags: + - showroom-user-security + + - name: Clone primary showroom repo and inject externals (vars, html templates) + ansible.builtin.include_tasks: + file: 30-showroom-clone-and-inject.yml + tags: + - showroom-clone-and-inject + + - name: Render antora content + ansible.builtin.include_tasks: + file: 40-showroom-render.yml + tags: + - showroom-render + + - name: Create, enable, start showroom systemd service + ansible.builtin.include_tasks: + file: 50-showroom-service.yml + tags: + - showroom-services + + - name: Validate showroom service and output view url(s) + ansible.builtin.include_tasks: + file: 60-showroom-verify.yml + tags: + - showroom-verify + +- name: Running workload removal tasks + when: ACTION | default("provision") == "destroy" + ansible.builtin.debug: + msg: >- + vm_workload_showroom destroy is a no-op. + The VM itself is being torn down. From 2e821074026005cf7ae69b342ff3f2705903184d Mon Sep 17 00:00:00 2001 From: Andrew Jones Date: Mon, 3 Aug 2026 16:49:25 +1000 Subject: [PATCH 5/8] fix(ocp4_workload_showroom): harden destroy to never block cluster teardown - Replace prepare_variables.yaml include with inline user_data lookup that cannot fail (the assert on guid and content_git_repo is only needed during provision, not destroy) - Switch from ignore_errors: true to failed_when: false (cleaner output, consistent with ocp_console_embed destroy) - Add namespace-removal wait loops so destroy confirms namespaces are fully gone before returning - Align retry windows to retries: 30, delay: 10 (300s total) to handle large multi-user deployments Ref: GPTEINFRA-16638 Co-authored-by: Cursor --- .../tasks/remove_workload.yml | 70 +++++++++++++++---- 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/roles/ocp4_workload_showroom/tasks/remove_workload.yml b/roles/ocp4_workload_showroom/tasks/remove_workload.yml index 5d7a18f..ac531e0 100644 --- a/roles/ocp4_workload_showroom/tasks/remove_workload.yml +++ b/roles/ocp4_workload_showroom/tasks/remove_workload.yml @@ -1,6 +1,14 @@ --- -- name: Prepare Variables - ansible.builtin.include_tasks: prepare_variables.yaml +# Resolve user data without asserting provision-time variables. +# Only guid and the user list are needed to compute namespace names. +# The namespace default (showroom-{{ guid | default('00000') }}) from +# defaults/main.yaml always resolves, even if guid is missing. +- name: Set showroom_user_data fact for destroy + ansible.builtin.set_fact: + _showroom_user_data: >- + {{ _showroom_user_data | default({}) + | combine(lookup('agnosticd.core.agnosticd_user_data', '*')) }} + failed_when: false - name: Remove Namespace (single user) when: not _showroom_user_data.users is defined @@ -14,11 +22,26 @@ kind: Namespace metadata: name: "{{ ocp4_workload_showroom_namespace }}" - ignore_errors: true - register: remove_ns_single - retries: 20 - delay: 3 - until: remove_ns_single is not failed + register: r_remove_ns_single + retries: 30 + delay: 10 + until: r_remove_ns_single is not failed + failed_when: false + +- name: Wait for namespace to be fully removed (single user) + when: not _showroom_user_data.users is defined + kubernetes.core.k8s_info: + host: "{{ ocp4_workload_showroom_openshift_api_url | default(omit) }}" + api_key: "{{ ocp4_workload_showroom_openshift_api_token | default(omit) }}" + validate_certs: false + api_version: v1 + kind: Namespace + name: "{{ ocp4_workload_showroom_namespace }}" + register: r_ns_single + retries: 30 + delay: 10 + until: r_ns_single.resources | length == 0 + failed_when: false - name: Remove Namespaces (multi user) when: _showroom_user_data.users is defined @@ -35,11 +58,34 @@ kind: Namespace metadata: name: "{{ _showroom_namespace }}" - ignore_errors: true - register: remove_ns_multi - retries: 20 - delay: 3 - until: remove_ns_multi is not failed + register: r_remove_ns_multi + retries: 30 + delay: 10 + until: r_remove_ns_multi is not failed + failed_when: false + loop: >- + {{ _showroom_user_data.users | dict2items }} + loop_control: + loop_var: _showroom_users_item + label: "{{ _showroom_users_item.key }}" + +- name: Wait for namespaces to be fully removed (multi user) + when: _showroom_user_data.users is defined + vars: + _showroom_user: "{{ _showroom_users_item.key }}" + _showroom_namespace: "{{ ocp4_workload_showroom_namespace }}-{{ _showroom_user }}" + kubernetes.core.k8s_info: + host: "{{ ocp4_workload_showroom_openshift_api_url | default(omit) }}" + api_key: "{{ ocp4_workload_showroom_openshift_api_token | default(omit) }}" + validate_certs: false + api_version: v1 + kind: Namespace + name: "{{ _showroom_namespace }}" + register: r_ns_multi + retries: 30 + delay: 10 + until: r_ns_multi.resources | length == 0 + failed_when: false loop: >- {{ _showroom_user_data.users | dict2items }} loop_control: From aa920ad15845c076a6097b610ae06ba56a284646 Mon Sep 17 00:00:00 2001 From: Andrew Jones Date: Mon, 3 Aug 2026 16:54:34 +1000 Subject: [PATCH 6/8] =?UTF-8?q?fix(ocp=5Fconsole=5Fembed):=20no-op=20destr?= =?UTF-8?q?oy=20=E2=80=94=20cluster=20teardown=20handles=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The destroy path attempted to cleanly revert IngressController headers, remove the webhook, delete RBAC, and wait for router rollout. In practice the cluster DNS is already torn down by the time this runs, causing a 20-minute retry loop before the job fails. All resources created by this role are cluster-scoped and disappear with the cluster itself — no external state (S3, DNS, etc.) to clean. Replace with a debug no-op. Co-authored-by: Cursor --- .../tasks/remove_workload.yml | 89 +++---------------- 1 file changed, 10 insertions(+), 79 deletions(-) diff --git a/roles/ocp4_workload_ocp_console_embed/tasks/remove_workload.yml b/roles/ocp4_workload_ocp_console_embed/tasks/remove_workload.yml index a129513..b224b5d 100644 --- a/roles/ocp4_workload_ocp_console_embed/tasks/remove_workload.yml +++ b/roles/ocp4_workload_ocp_console_embed/tasks/remove_workload.yml @@ -1,80 +1,11 @@ --- -# Remove the MutatingWebhookConfiguration first so the webhook stops -# intercepting route writes before we tear down the serving infrastructure. -- name: Remove MutatingWebhookConfiguration - kubernetes.core.k8s: - state: absent - api_version: admissionregistration.k8s.io/v1 - kind: MutatingWebhookConfiguration - name: "{{ ocp_console_embed_name }}-oauth-route" - register: r_remove_webhook - failed_when: false - -# Revert the IngressController to default header behaviour. -# Removing httpHeaders.actions lets the ingress operator restore its -# defaults (X-Frame-Options: DENY, no custom CSP). -- name: Reset IngressController httpHeaders - kubernetes.core.k8s: - api_version: operator.openshift.io/v1 - kind: IngressController - name: default - namespace: openshift-ingress-operator - state: present - definition: - spec: - httpHeaders: - actions: - response: [] - -- name: Wait for router rollout to complete - ansible.builtin.include_tasks: wait-router-rollout.yml - vars: - _ocp_console_embed_wait_reason: IngressController header reset - -# Remove the RBAC resources from openshift-authentication (cluster-scoped -# within that namespace, not cleaned up by namespace deletion below). -- name: Remove RBAC for route reconciliation - kubernetes.core.k8s: - state: absent - definition: "{{ __ocp_console_embed_rbac }}" - loop: "{{ lookup('template', 'rbac.yaml.j2') | from_yaml_all | list }}" - loop_control: - loop_var: __ocp_console_embed_rbac - register: r_remove_rbac - failed_when: false - -# Deleting the namespace removes the Deployment, Service, ServiceAccount, -# ConfigMaps, Secrets, and all other namespaced resources. -- name: Remove namespace - kubernetes.core.k8s: - state: absent - api_version: v1 - kind: Namespace - name: "{{ ocp_console_embed_namespace }}" - -- name: Wait for namespace to be fully removed - kubernetes.core.k8s_info: - api_version: v1 - kind: Namespace - name: "{{ ocp_console_embed_namespace }}" - register: r_ns - retries: 30 - delay: 10 - until: r_ns.resources | length == 0 - -# With the webhook gone the authentication operator will reconcile the -# OAuth route back to passthrough on its own. Wait for that to happen -# so the caller knows the cluster is fully restored. -- name: Wait for OAuth route to revert to passthrough - kubernetes.core.k8s_info: - api_version: route.openshift.io/v1 - kind: Route - name: oauth-openshift - namespace: openshift-authentication - register: r_oauth_route - retries: "{{ ocp_console_embed_verify_retries }}" - delay: "{{ ocp_console_embed_verify_delay }}" - until: - - r_oauth_route.resources | length > 0 - - r_oauth_route.resources[0].spec.tls.termination == 'passthrough' - failed_when: false +# No-op: all resources created by this role live on the cluster itself +# (MutatingWebhookConfiguration, IngressController patch, RBAC, +# namespace). They are cleaned up when the cluster is destroyed. +# Attempting active teardown races with cluster deletion and causes +# 20-minute timeout failures when DNS is already gone. +- name: "Removal skipped — cluster teardown handles cleanup" + ansible.builtin.debug: + msg: >- + Skipping active removal of console-embed resources. + All resources are cluster-scoped and will be destroyed with the cluster. From 26eb830c4edf7264885fbfaa762ef26228ee05a1 Mon Sep 17 00:00:00 2001 From: Andrew Jones Date: Mon, 3 Aug 2026 18:08:52 +1000 Subject: [PATCH 7/8] fix(ocp4_workload_showroom): harden remove_workload against edge cases - Add warning when user_data lookup fails (prevents silent namespace orphaning) - Use `| default([])` on k8s_info until conditions to survive API errors - Replace unsafe `is defined` checks with `| default(none) is none` pattern Co-authored-by: Cursor --- .../tasks/remove_workload.yml | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/roles/ocp4_workload_showroom/tasks/remove_workload.yml b/roles/ocp4_workload_showroom/tasks/remove_workload.yml index ac531e0..6aa072c 100644 --- a/roles/ocp4_workload_showroom/tasks/remove_workload.yml +++ b/roles/ocp4_workload_showroom/tasks/remove_workload.yml @@ -10,8 +10,15 @@ | combine(lookup('agnosticd.core.agnosticd_user_data', '*')) }} failed_when: false +- name: Warn if user_data lookup failed (may orphan per-user namespaces) + when: _showroom_user_data | default({}) | length == 0 + ansible.builtin.debug: + msg: >- + WARNING: agnosticd_user_data lookup returned nothing. + If this is a multi-user environment, per-user namespaces will NOT be cleaned up. + - name: Remove Namespace (single user) - when: not _showroom_user_data.users is defined + when: _showroom_user_data.users | default(none) is none kubernetes.core.k8s: host: "{{ ocp4_workload_showroom_openshift_api_url | default(omit) }}" api_key: "{{ ocp4_workload_showroom_openshift_api_token | default(omit) }}" @@ -29,7 +36,7 @@ failed_when: false - name: Wait for namespace to be fully removed (single user) - when: not _showroom_user_data.users is defined + when: _showroom_user_data.users | default(none) is none kubernetes.core.k8s_info: host: "{{ ocp4_workload_showroom_openshift_api_url | default(omit) }}" api_key: "{{ ocp4_workload_showroom_openshift_api_token | default(omit) }}" @@ -40,11 +47,11 @@ register: r_ns_single retries: 30 delay: 10 - until: r_ns_single.resources | length == 0 + until: r_ns_single.resources | default([]) | length == 0 failed_when: false - name: Remove Namespaces (multi user) - when: _showroom_user_data.users is defined + when: _showroom_user_data.users | default(none) is not none vars: _showroom_user: "{{ _showroom_users_item.key }}" _showroom_namespace: "{{ ocp4_workload_showroom_namespace }}-{{ _showroom_user }}" @@ -70,7 +77,7 @@ label: "{{ _showroom_users_item.key }}" - name: Wait for namespaces to be fully removed (multi user) - when: _showroom_user_data.users is defined + when: _showroom_user_data.users | default(none) is not none vars: _showroom_user: "{{ _showroom_users_item.key }}" _showroom_namespace: "{{ ocp4_workload_showroom_namespace }}-{{ _showroom_user }}" @@ -84,7 +91,7 @@ register: r_ns_multi retries: 30 delay: 10 - until: r_ns_multi.resources | length == 0 + until: r_ns_multi.resources | default([]) | length == 0 failed_when: false loop: >- {{ _showroom_user_data.users | dict2items }} From 6853c6bae096b5e3d13ab653310b277c1e92b015 Mon Sep 17 00:00:00 2001 From: Andrew Jones Date: Tue, 4 Aug 2026 08:44:08 +1000 Subject: [PATCH 8/8] fix(ocp_console_embed): replace non-ASCII em dash in task name Use plain hyphen for CI/log parser compatibility. Co-authored-by: Cursor --- roles/ocp4_workload_ocp_console_embed/tasks/remove_workload.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/ocp4_workload_ocp_console_embed/tasks/remove_workload.yml b/roles/ocp4_workload_ocp_console_embed/tasks/remove_workload.yml index b224b5d..35f6a18 100644 --- a/roles/ocp4_workload_ocp_console_embed/tasks/remove_workload.yml +++ b/roles/ocp4_workload_ocp_console_embed/tasks/remove_workload.yml @@ -4,7 +4,7 @@ # namespace). They are cleaned up when the cluster is destroyed. # Attempting active teardown races with cluster deletion and causes # 20-minute timeout failures when DNS is already gone. -- name: "Removal skipped — cluster teardown handles cleanup" +- name: "Removal skipped - cluster teardown handles cleanup" ansible.builtin.debug: msg: >- Skipping active removal of console-embed resources.