From 198235113e3d31c8b4ee478074830b48837a3b49 Mon Sep 17 00:00:00 2001 From: malingatembo Date: Wed, 30 Sep 2026 20:53:04 +0200 Subject: [PATCH 01/13] [ci] Add mock LLM for Lightspeed deployment testing Deploy a mock provider so Lightspeed can be tested without external LLM credentials. Support ordinary and streamed chat completions required by the current provider adapter. Related-Issue: OSPRH-33344 Signed-off-by: malingatembo --- .../install-openstack-lightspeed.yml | 143 +++++++++++++++++- 1 file changed, 138 insertions(+), 5 deletions(-) diff --git a/hooks/playbooks/install-openstack-lightspeed.yml b/hooks/playbooks/install-openstack-lightspeed.yml index aabe77370..82ea3ff8b 100644 --- a/hooks/playbooks/install-openstack-lightspeed.yml +++ b/hooks/playbooks/install-openstack-lightspeed.yml @@ -160,6 +160,139 @@ # STEP 3: Create prerequisites (secrets and certificates) + - name: Create mock LLM API server code ConfigMap + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: ConfigMap + metadata: + name: mock-llm-code + namespace: "{{ openstack_lightspeed_namespace }}" + data: + app.py: | + import json + + from fastapi import FastAPI, Request + from fastapi.responses import JSONResponse, StreamingResponse + + app = FastAPI() + @app.post("/v1/chat/completions") + async def completions_post(request: Request): + try: + body = await request.json() + except Exception: + body = {} + + if not isinstance(body, dict): + body = {} + model = body.get("model") or "gpt-3.5-turbo" + + # The OpenAI API expects 'messages' (for chat) or 'prompt' (for completions), but for mock, we will accept either + response = { + "id": "cmpl-123", + "object": "chat.completion", + "created": 1234567890, + "model": model, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello, this is a dummy chat completion." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 5, + "completion_tokens": 7, + "total_tokens": 12 + } + } + + # The provider adapter requests streaming even for /v1/query. + if body.get("stream"): + def stream_chunks(): + chunk = { + "id": response["id"], + "object": "chat.completion.chunk", + "created": response["created"], + "model": model, + } + content = response["choices"][0]["message"]["content"] + for delta, finish_reason in [ + ({"role": "assistant"}, None), + ({"content": content}, None), + ({}, "stop"), + ]: + chunk["choices"] = [{ + "index": 0, + "delta": delta, + "finish_reason": finish_reason, + }] + yield "data: " + json.dumps(chunk) + "\n\n" + + stream_options = body.get("stream_options") or {} + if stream_options.get("include_usage"): + chunk["choices"] = [] + chunk["usage"] = response["usage"] + yield "data: " + json.dumps(chunk) + "\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse(stream_chunks(), media_type="text/event-stream") + + return JSONResponse(content=response) + + - name: Create mock LLM API server Pod + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Pod + metadata: + name: mock-llm-api-server-pod + namespace: "{{ openstack_lightspeed_namespace }}" + labels: + app: mock-llm-api-server-pod + spec: + containers: + - name: mock-llm-api-server + image: registry.redhat.io/ubi8/python-311:latest + ports: + - containerPort: 8000 + volumeMounts: + - name: app-code + mountPath: /app + workingDir: /app + command: + - sh + - -c + - | + pip install "fastapi[standard]" && \ + uvicorn app:app --host 0.0.0.0 --port 8000 + volumes: + - name: app-code + configMap: + name: mock-llm-code + + - name: Create mock LLM API server Service + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Service + metadata: + name: mock-llm-api-server-pod + namespace: "{{ openstack_lightspeed_namespace }}" + spec: + selector: + app: mock-llm-api-server-pod + ports: + - protocol: TCP + port: 8000 + targetPort: 8000 + - name: Create LLM API token secret kubernetes.core.k8s: state: present @@ -212,9 +345,7 @@ namespace: "{{ openstack_lightspeed_namespace }}" register: cert_check failed_when: cert_check.resources | length == 0 - when: - - cifmw_openstack_lightspeed_ca_cert is not defined - - cifmw_openstack_lightspeed_ca_cert_url is not defined + when: cifmw_openstack_lightspeed_ca_cert_url is defined or cifmw_openstack_lightspeed_ca_cert is defined - name: Create test-operator dummy config ConfigMap kubernetes.core.k8s: @@ -300,7 +431,8 @@ llmEndpointType: "{{ cifmw_openstack_lightspeed_llm_endpoint_type | default('openai') }}" modelName: "{{ cifmw_openstack_lightspeed_model_name }}" llmCredentials: "{{ cifmw_openstack_lightspeed_llm_credentials | default('openstack-lightspeed-apitoken') }}" - tlsCACertBundle: "{{ cifmw_openstack_lightspeed_tls_ca_cert_bundle | default('openstack-lightspeed-certs') if (cifmw_openstack_lightspeed_ca_cert is defined or cifmw_openstack_lightspeed_ca_cert_url is defined) else omit }}" + tlsCACertBundle: "{{ cifmw_openstack_lightspeed_tls_ca_cert_bundle | default('openstack-lightspeed-certs') if (cifmw_openstack_lightspeed_ca_cert is defined + or cifmw_openstack_lightspeed_ca_cert_url is defined) else omit }}" - name: Wait for OpenStackLightspeed CR to be ready kubernetes.core.k8s_info: @@ -314,7 +446,8 @@ - cr_status.resources | length > 0 - cr_status.resources[0].status is defined - cr_status.resources[0].status.conditions is defined - - cr_status.resources[0].status.conditions | selectattr('type', 'equalto', 'Ready') | map(attribute='status') | select('equalto', 'True') | list | length > 0 + - cr_status.resources[0].status.conditions | selectattr('type', 'equalto', 'Ready') | map(attribute='status') | select('equalto', 'True') | list | + length > 0 retries: 30 delay: 10 From 19569170c7189be8d962d20623664535588c581e Mon Sep 17 00:00:00 2001 From: malingatembo Date: Mon, 5 Oct 2026 14:50:23 +0200 Subject: [PATCH 02/13] [ci] Allow Lightspeed more time to become ready on CRC The upstream mock-provider deployment timed out while application images were still starting. The collected custom resource became Ready about 50 seconds after the playbook failed. Increase the default CR readiness retries from 30 to 120 while retaining the ten-second polling interval, Ready condition and bounded failure. Related-Issue: #OSPRH-33344 Signed-off-by: malingatembo --- hooks/playbooks/install-openstack-lightspeed.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/playbooks/install-openstack-lightspeed.yml b/hooks/playbooks/install-openstack-lightspeed.yml index 82ea3ff8b..7a67aa1ec 100644 --- a/hooks/playbooks/install-openstack-lightspeed.yml +++ b/hooks/playbooks/install-openstack-lightspeed.yml @@ -448,7 +448,7 @@ - cr_status.resources[0].status.conditions is defined - cr_status.resources[0].status.conditions | selectattr('type', 'equalto', 'Ready') | map(attribute='status') | select('equalto', 'True') | list | length > 0 - retries: 30 + retries: "{{ cifmw_openstack_lightspeed_ready_retries | default(120) | int }}" delay: 10 - name: Display deployment summary From 91afe693bc6f1c64dea00e1baf146587e23dbf01 Mon Sep 17 00:00:00 2001 From: malingatembo Date: Tue, 6 Oct 2026 10:54:16 +0200 Subject: [PATCH 03/13] [ci] Update Lightspeed CR for multi-model configuration The operator now requires models and defaultModel. Move the existing provider settings into one configured model and select it as the default so the deployment hook can create the CR against the current operator. Related-Issue: OSPRH-33344 Signed-off-by: malingatembo --- hooks/playbooks/install-openstack-lightspeed.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/hooks/playbooks/install-openstack-lightspeed.yml b/hooks/playbooks/install-openstack-lightspeed.yml index 7a67aa1ec..cbfd648eb 100644 --- a/hooks/playbooks/install-openstack-lightspeed.yml +++ b/hooks/playbooks/install-openstack-lightspeed.yml @@ -427,10 +427,13 @@ name: "{{ cifmw_openstack_lightspeed_cr_name | default('openstacklightspeed-sample') }}" namespace: "{{ openstack_lightspeed_namespace }}" spec: - llmEndpoint: "{{ cifmw_openstack_lightspeed_llm_endpoint }}" - llmEndpointType: "{{ cifmw_openstack_lightspeed_llm_endpoint_type | default('openai') }}" - modelName: "{{ cifmw_openstack_lightspeed_model_name }}" - llmCredentials: "{{ cifmw_openstack_lightspeed_llm_credentials | default('openstack-lightspeed-apitoken') }}" + defaultModel: default-model + models: + - name: default-model + llmEndpoint: "{{ cifmw_openstack_lightspeed_llm_endpoint }}" + llmEndpointType: "{{ cifmw_openstack_lightspeed_llm_endpoint_type | default('openai') }}" + modelName: "{{ cifmw_openstack_lightspeed_model_name }}" + llmCredentials: "{{ cifmw_openstack_lightspeed_llm_credentials | default('openstack-lightspeed-apitoken') }}" tlsCACertBundle: "{{ cifmw_openstack_lightspeed_tls_ca_cert_bundle | default('openstack-lightspeed-certs') if (cifmw_openstack_lightspeed_ca_cert is defined or cifmw_openstack_lightspeed_ca_cert_url is defined) else omit }}" From 887a6e24d009fd296dabd9f362383d9315f49d1a Mon Sep 17 00:00:00 2001 From: malingatembo Date: Thu, 8 Oct 2026 12:40:53 +0200 Subject: [PATCH 04/13] [ci] Harden Lightspeed deployment and run functional tests Wait for mock readiness, reload changed mock code, and allow bounded application startup. Run the prepared Lightspeed test checkout with an explicit client identity and retain JUnit/logs before propagating failure. Remove private client token files and API tunnels on success or failure. Related-Issue: OSPRH-33344 Signed-off-by: malingatembo --- docs/source/usage/lightspeed-testing.md | 104 +++ .../install-openstack-lightspeed.yml | 783 +++++++++--------- .../run-openstack-lightspeed-tests.yml | 144 ++++ .../playbooks/tasks/lightspeed-test-stage.yml | 43 + 4 files changed, 663 insertions(+), 411 deletions(-) create mode 100644 docs/source/usage/lightspeed-testing.md create mode 100644 hooks/playbooks/run-openstack-lightspeed-tests.yml create mode 100644 hooks/playbooks/tasks/lightspeed-test-stage.yml diff --git a/docs/source/usage/lightspeed-testing.md b/docs/source/usage/lightspeed-testing.md new file mode 100644 index 000000000..999a7f76f --- /dev/null +++ b/docs/source/usage/lightspeed-testing.md @@ -0,0 +1,104 @@ +# OpenStack Lightspeed deployment and functional tests + +Use two ordered `pre_tests` hooks in the Lightspeed deployment job: + +```yaml +pre_tests_01_deploy_lightspeed_operator: + source: install-openstack-lightspeed.yml + type: playbook +pre_tests_02_run_lightspeed_tests: + source: run-openstack-lightspeed-tests.yml + type: playbook +cifmw_openstack_lightspeed_mock_enabled: true +``` + +The job must require `openstack-k8s-operators/lightspeed-tests` so workspace +preparation supplies the tested revision on the controller. Include a +`Depends-On: ` in the consuming PR description +while test-runner changes remain unmerged. The hook uses the prepared checkout; +it does not clone an unrelated `main` branch. + +The deployment hook installs the catalog, OLM subscription, credential Secret, +and OpenStackLightspeed custom resource. Mock deployment is opt-in. It waits +for the mock Pod to be Ready, restarts that Pod after its code changes, and +allows up to 120 retries at ten-second intervals for application readiness. +Set `cifmw_openstack_lightspeed_ready_retries` to change that bounded wait. +Failure diagnostics include Pods, Deployments and events, followed by failure. + +The test hook creates a dedicated `lightspeed-tests` ServiceAccount and grants +only GET on `/ls-access`. It obtains a short-lived client token, stores it in a +private temporary directory, and forwards the application Service to an unused +loopback port. The client token is distinct from the provider API credential. +The wrapper reads the token file in Python; token contents are not runner vars +or published artifacts. The hook cleans up the token and its tunnel on failure +as well as success. RBAC objects remain available for subsequent runs. + +## Integration tests + +The `baseline` run selects `tests/integration`: authenticated queries, metrics, +OKP grounding and missing-authentication rejection. Configuration unit tests +are checked separately. The hook does not alter the provider Service. + +The hook invokes the Lightspeed repository's Ansible wrapper with a maximum +runtime of 1800 seconds. The wrapper uses Python 3.12 and locked dependencies, +saves pytest output, and requires pytest exit code zero and a JUnit report. +A test failure fails the wrapper, the hook and the calling job. The wrapper +does not independently validate XML contents or reject an entirely skipped +suite; inspect the reported test counts as part of CI acceptance. + +## Parameters and artifacts + +`cifmw_lightspeed_tests_source` defaults to +`~/src/github.com/openstack-k8s-operators/lightspeed-tests` on the controller. +Override it for a local checkout. `cifmw_openshift_kubeconfig` selects cluster +access, falling back to CRC's kubeconfig. The namespace is inherited from +`cifmw_openstack_lightspeed_namespace`, defaulting to `openstack-lightspeed`. +`cifmw_lightspeed_tests_timeout` controls individual API requests (default 120 +seconds). + +Artifacts default to `{{ cifmw_basedir }}/tests/lightspeed`: source revision, +port-forward output, baseline JUnit XML, pytest log and Ansible runner log. +Override `cifmw_lightspeed_tests_artifacts` for local testing. The existing +framework collector publishes its `tests` directory. Inspect the deployment +job's actual result as well as its reports when the job is non-voting. + +From the operator PR's Zuul bot comment, open the buildset, then +`lightspeed-operator-deployment-crc`, then its logs. Relative to that build's +log directory, look for: + +- `controller/ci-framework-data/logs/pre_tests_01_deploy_lightspeed_operator.log`: + installation and the Ready wait. +- `controller/ci-framework-data/logs/pre_tests_02_run_lightspeed_tests.log`: + authentication setup, API tunnel, test execution and cleanup. +- `controller/ci-framework-data/tests/lightspeed/baseline.xml`: individual test + cases and their results. +- `controller/ci-framework-data/tests/lightspeed/baseline.xml.log`: pytest output. +- `controller/ci-framework-data/tests/lightspeed/baseline-ansible.log`: dependency + setup, runner output and exit code, including setup failures. +- `controller/ci-framework-data/tests/lightspeed/source-revision.txt`: test checkout + revision and working-tree changes. Use `zuul-info/inventory.yaml` to identify + the PR revisions; Zuul's checkout HEAD may be a speculative merge commit. + +Accept the run only when the deployment job itself succeeds and the integration +report contains executed passing tests. A green buildset alone can conceal a +non-voting deployment failure. KUTTL is a separate job and suite. + +## Submitting the coordinated changes + +The test-runner changes need a `lightspeed-tests` PR as well as the framework +and operator PRs. Before rechecking the operator PR, put both the framework PR +URL and the actual test-runner PR URL in its description, each on its own +`Depends-On:` line. A `required-projects` entry supplies a checkout; it does not +by itself select an unmerged PR. Do not submit a placeholder dependency URL. + +This integration runs the existing suite on the CI controller through its +Ansible wrapper. It does not create an `AnsibleTest` resource or replace the +job's inherited test-operator stages. A future test-operator implementation +would need explicit token mounting and a way to use the prepared test revision. + +CRC validates the same hooks without waiting for a complete Zuul deployment. +It does not validate workspace preparation, content-provider image selection, +cross-repository dependencies or published artifact collection. A final combined +Zuul run is required. Mock results do not establish real-provider credentials, +TLS trust or model quality. The current test client's TLS verification remains +disabled for the disposable test environment. diff --git a/hooks/playbooks/install-openstack-lightspeed.yml b/hooks/playbooks/install-openstack-lightspeed.yml index cbfd648eb..defe10bdf 100644 --- a/hooks/playbooks/install-openstack-lightspeed.yml +++ b/hooks/playbooks/install-openstack-lightspeed.yml @@ -31,428 +31,389 @@ KUBECONFIG: "{{ kubeconfig_path }}" tasks: - # STEP 1: Deploy OpenStack Lightspeed catalog - - - name: Create CatalogSource for OpenStack Lightspeed - kubernetes.core.k8s: - state: present - definition: - apiVersion: operators.coreos.com/v1alpha1 - kind: CatalogSource - metadata: + - name: Install and wait for OpenStack Lightspeed + block: + # STEP 1: Deploy OpenStack Lightspeed catalog + + - name: Create CatalogSource for OpenStack Lightspeed + kubernetes.core.k8s: + state: present + definition: + apiVersion: operators.coreos.com/v1alpha1 + kind: CatalogSource + metadata: + name: "{{ openstack_lightspeed_catalog_name }}" + namespace: "openshift-marketplace" + spec: + sourceType: grpc + image: "{{ openstack_lightspeed_catalog_image }}" + displayName: "OpenStack Lightspeed Operator" + publisher: "Red Hat" + + - name: Wait for CatalogSource to be ready + kubernetes.core.k8s_info: + api_version: operators.coreos.com/v1alpha1 + kind: CatalogSource name: "{{ openstack_lightspeed_catalog_name }}" namespace: "openshift-marketplace" - spec: - sourceType: grpc - image: "{{ openstack_lightspeed_catalog_image }}" - displayName: "OpenStack Lightspeed Operator" - publisher: "Red Hat" - - - name: Wait for CatalogSource to be ready - kubernetes.core.k8s_info: - api_version: operators.coreos.com/v1alpha1 - kind: CatalogSource - name: "{{ openstack_lightspeed_catalog_name }}" - namespace: "openshift-marketplace" - register: catalog_source - until: - - catalog_source.resources is defined - - catalog_source.resources | length > 0 - - catalog_source.resources[0].status.connectionState.lastObservedState is defined - - catalog_source.resources[0].status.connectionState.lastObservedState == "READY" - retries: 30 - delay: 10 - - # STEP 2: Deploy OpenStack Lightspeed operator - # Note: OpenStack Lightspeed operator will automatically install - # and manage the OpenShift Lightspeed operator as a dependency - - - name: Create namespace for OpenStack Lightspeed - kubernetes.core.k8s: - state: present - definition: - apiVersion: v1 - kind: Namespace - metadata: - name: "{{ openstack_lightspeed_namespace }}" - - - name: Create ClusterRole for Lightspeed tests - kubernetes.core.k8s: - state: present - definition: - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRole - metadata: - name: "lightspeed-test-role" - rules: - - nonResourceURLs: ["/ls-access"] - verbs: ["get"] - register: _result - retries: 5 - delay: 10 - until: _result is not failed - - - name: Create ClusterRoleBinding for Lightspeed tests - kubernetes.core.k8s: - state: present - definition: - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRoleBinding - metadata: - name: "lightspeed-test-role-binding" - roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: "lightspeed-test-role" - subjects: - - kind: ServiceAccount - name: "default" - namespace: "{{ openstack_lightspeed_namespace }}" - register: _result - retries: 5 - delay: 10 - until: _result is not failed - - - name: Create OperatorGroup for OpenStack Lightspeed - kubernetes.core.k8s: - state: present - definition: - apiVersion: operators.coreos.com/v1 - kind: OperatorGroup - metadata: - name: "{{ openstack_lightspeed_operator_group }}" + register: catalog_source + until: + - catalog_source.resources is defined + - catalog_source.resources | length > 0 + - catalog_source.resources[0].status.connectionState.lastObservedState is defined + - catalog_source.resources[0].status.connectionState.lastObservedState == "READY" + retries: 30 + delay: 10 + + # STEP 2: Deploy OpenStack Lightspeed operator + # Note: OpenStack Lightspeed operator will automatically install + # and manage the OpenShift Lightspeed operator as a dependency + + - name: Create namespace for OpenStack Lightspeed + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Namespace + metadata: + name: "{{ openstack_lightspeed_namespace }}" + + - name: Create OperatorGroup for OpenStack Lightspeed + kubernetes.core.k8s: + state: present + definition: + apiVersion: operators.coreos.com/v1 + kind: OperatorGroup + metadata: + name: "{{ openstack_lightspeed_operator_group }}" + namespace: "{{ openstack_lightspeed_namespace }}" + spec: + targetNamespaces: + - "{{ openstack_lightspeed_namespace }}" + + - name: Subscribe to OpenStack Lightspeed operator + kubernetes.core.k8s: + state: present + definition: + apiVersion: operators.coreos.com/v1alpha1 + kind: Subscription + metadata: + name: "openstack-lightspeed-operator" + namespace: "{{ openstack_lightspeed_namespace }}" + spec: + channel: "alpha" + name: "openstack-lightspeed-operator" + source: "{{ openstack_lightspeed_catalog_name }}" + sourceNamespace: "openshift-marketplace" + installPlanApproval: "Automatic" + + - name: Wait for OpenStack Lightspeed CSV to be ready + kubernetes.core.k8s_info: + api_version: operators.coreos.com/v1alpha1 + kind: ClusterServiceVersion namespace: "{{ openstack_lightspeed_namespace }}" - spec: - targetNamespaces: - - "{{ openstack_lightspeed_namespace }}" - - - name: Subscribe to OpenStack Lightspeed operator - kubernetes.core.k8s: - state: present - definition: - apiVersion: operators.coreos.com/v1alpha1 - kind: Subscription - metadata: - name: "openstack-lightspeed-operator" - namespace: "{{ openstack_lightspeed_namespace }}" - spec: - channel: "alpha" - name: "openstack-lightspeed-operator" - source: "{{ openstack_lightspeed_catalog_name }}" - sourceNamespace: "openshift-marketplace" - installPlanApproval: "Automatic" - - - name: Wait for OpenStack Lightspeed CSV to be ready - kubernetes.core.k8s_info: - api_version: operators.coreos.com/v1alpha1 - kind: ClusterServiceVersion - namespace: "{{ openstack_lightspeed_namespace }}" - label_selectors: - - "operators.coreos.com/openstack-lightspeed-operator.{{ openstack_lightspeed_namespace }}" - register: osls_csv - until: - - osls_csv.resources is defined - - osls_csv.resources | length > 0 - - osls_csv.resources[0].status.phase is defined - - osls_csv.resources[0].status.phase == "Succeeded" - retries: 30 - delay: 10 - - # STEP 3: Create prerequisites (secrets and certificates) - - - name: Create mock LLM API server code ConfigMap - kubernetes.core.k8s: - state: present - definition: - apiVersion: v1 - kind: ConfigMap - metadata: - name: mock-llm-code - namespace: "{{ openstack_lightspeed_namespace }}" - data: - app.py: | - import json - - from fastapi import FastAPI, Request - from fastapi.responses import JSONResponse, StreamingResponse - - app = FastAPI() - @app.post("/v1/chat/completions") - async def completions_post(request: Request): - try: - body = await request.json() - except Exception: - body = {} - - if not isinstance(body, dict): - body = {} - model = body.get("model") or "gpt-3.5-turbo" - - # The OpenAI API expects 'messages' (for chat) or 'prompt' (for completions), but for mock, we will accept either - response = { - "id": "cmpl-123", - "object": "chat.completion", - "created": 1234567890, - "model": model, - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "Hello, this is a dummy chat completion." - }, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 5, - "completion_tokens": 7, - "total_tokens": 12 - } - } - - # The provider adapter requests streaming even for /v1/query. - if body.get("stream"): - def stream_chunks(): - chunk = { - "id": response["id"], - "object": "chat.completion.chunk", - "created": response["created"], + label_selectors: + - "operators.coreos.com/openstack-lightspeed-operator.{{ openstack_lightspeed_namespace }}" + register: osls_csv + until: + - osls_csv.resources is defined + - osls_csv.resources | length > 0 + - osls_csv.resources[0].status.phase is defined + - osls_csv.resources[0].status.phase == "Succeeded" + retries: 30 + delay: 10 + + # STEP 3: Create prerequisites (secrets and certificates) + + - name: Deploy the mock LLM provider + when: cifmw_openstack_lightspeed_mock_enabled | default(false) | bool + block: + - name: Create mock LLM API server code ConfigMap + register: mock_code + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: ConfigMap + metadata: + name: mock-llm-code + namespace: "{{ openstack_lightspeed_namespace }}" + data: + app.py: | + import json + + from fastapi import FastAPI, Request + from fastapi.responses import JSONResponse, StreamingResponse + + app = FastAPI() + @app.post("/v1/chat/completions") + async def completions_post(request: Request): + try: + body = await request.json() + except Exception: + body = {} + + if not isinstance(body, dict): + body = {} + model = body.get("model") or "gpt-3.5-turbo" + + # The OpenAI API expects 'messages' (for chat) or 'prompt' (for completions), but for mock, we will accept either + response = { + "id": "cmpl-123", + "object": "chat.completion", + "created": 1234567890, "model": model, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello, this is a dummy chat completion." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 5, + "completion_tokens": 7, + "total_tokens": 12 + } } - content = response["choices"][0]["message"]["content"] - for delta, finish_reason in [ - ({"role": "assistant"}, None), - ({"content": content}, None), - ({}, "stop"), - ]: - chunk["choices"] = [{ - "index": 0, - "delta": delta, - "finish_reason": finish_reason, - }] - yield "data: " + json.dumps(chunk) + "\n\n" - - stream_options = body.get("stream_options") or {} - if stream_options.get("include_usage"): - chunk["choices"] = [] - chunk["usage"] = response["usage"] - yield "data: " + json.dumps(chunk) + "\n\n" - yield "data: [DONE]\n\n" - - return StreamingResponse(stream_chunks(), media_type="text/event-stream") - - return JSONResponse(content=response) - - - name: Create mock LLM API server Pod - kubernetes.core.k8s: - state: present - definition: - apiVersion: v1 - kind: Pod - metadata: - name: mock-llm-api-server-pod - namespace: "{{ openstack_lightspeed_namespace }}" - labels: - app: mock-llm-api-server-pod - spec: - containers: - - name: mock-llm-api-server - image: registry.redhat.io/ubi8/python-311:latest - ports: - - containerPort: 8000 - volumeMounts: - - name: app-code - mountPath: /app - workingDir: /app - command: - - sh - - -c - - | - pip install "fastapi[standard]" && \ - uvicorn app:app --host 0.0.0.0 --port 8000 - volumes: - - name: app-code - configMap: - name: mock-llm-code - - - name: Create mock LLM API server Service - kubernetes.core.k8s: - state: present - definition: - apiVersion: v1 - kind: Service - metadata: - name: mock-llm-api-server-pod - namespace: "{{ openstack_lightspeed_namespace }}" - spec: - selector: - app: mock-llm-api-server-pod - ports: - - protocol: TCP - port: 8000 - targetPort: 8000 - - - name: Create LLM API token secret - kubernetes.core.k8s: - state: present - definition: - apiVersion: v1 - kind: Secret - metadata: + + # The provider adapter requests streaming even for /v1/query. + if body.get("stream"): + def stream_chunks(): + chunk = { + "id": response["id"], + "object": "chat.completion.chunk", + "created": response["created"], + "model": model, + } + content = response["choices"][0]["message"]["content"] + for delta, finish_reason in [ + ({"role": "assistant"}, None), + ({"content": content}, None), + ({}, "stop"), + ]: + chunk["choices"] = [{ + "index": 0, + "delta": delta, + "finish_reason": finish_reason, + }] + yield "data: " + json.dumps(chunk) + "\n\n" + + stream_options = body.get("stream_options") or {} + if stream_options.get("include_usage"): + chunk["choices"] = [] + chunk["usage"] = response["usage"] + yield "data: " + json.dumps(chunk) + "\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse(stream_chunks(), media_type="text/event-stream") + + return JSONResponse(content=response) + + - name: Inspect the existing mock LLM Pod + kubernetes.core.k8s_info: + api_version: v1 + kind: Pod + name: mock-llm-api-server-pod + namespace: "{{ openstack_lightspeed_namespace }}" + register: existing_mock_pod + + - name: Restart mock LLM when its code or readiness configuration changes + kubernetes.core.k8s: + state: absent + api_version: v1 + kind: Pod + name: mock-llm-api-server-pod + namespace: "{{ openstack_lightspeed_namespace }}" + wait: true + when: >- + mock_code is changed or + (existing_mock_pod.resources | length > 0 and + existing_mock_pod.resources[0].spec.containers[0].readinessProbe is not defined) + + - name: Create mock LLM API server Pod + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Pod + metadata: + name: mock-llm-api-server-pod + namespace: "{{ openstack_lightspeed_namespace }}" + labels: + app: mock-llm-api-server-pod + spec: + containers: + - name: mock-llm-api-server + image: registry.redhat.io/ubi8/python-311:latest + readinessProbe: + tcpSocket: + port: 8000 + periodSeconds: 5 + ports: + - containerPort: 8000 + volumeMounts: + - name: app-code + mountPath: /app + workingDir: /app + command: + - sh + - -c + - | + pip install "fastapi[standard]" && \ + uvicorn app:app --host 0.0.0.0 --port 8000 + volumes: + - name: app-code + configMap: + name: mock-llm-code + + - name: Create mock LLM API server Service + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Service + metadata: + name: mock-llm-api-server-pod + namespace: "{{ openstack_lightspeed_namespace }}" + spec: + selector: + app: mock-llm-api-server-pod + ports: + - protocol: TCP + port: 8000 + targetPort: 8000 + + - name: Wait for mock LLM API server to be ready + kubernetes.core.k8s_info: + api_version: v1 + kind: Pod + name: mock-llm-api-server-pod + namespace: "{{ openstack_lightspeed_namespace }}" + wait: true + wait_condition: + type: Ready + status: "True" + wait_timeout: 300 + + - name: Create LLM API token secret + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Secret + metadata: + name: "{{ cifmw_openstack_lightspeed_llm_credentials | default('openstack-lightspeed-apitoken') }}" + namespace: "{{ openstack_lightspeed_namespace }}" + stringData: + apitoken: "{{ cifmw_openstack_lightspeed_api_token }}" + when: cifmw_openstack_lightspeed_api_token is defined + no_log: true + + - name: Verify LLM credentials secret exists + kubernetes.core.k8s_info: + kind: Secret name: "{{ cifmw_openstack_lightspeed_llm_credentials | default('openstack-lightspeed-apitoken') }}" namespace: "{{ openstack_lightspeed_namespace }}" - stringData: - apitoken: "{{ cifmw_openstack_lightspeed_api_token }}" - when: cifmw_openstack_lightspeed_api_token is defined - no_log: true - - - name: Verify LLM credentials secret exists - kubernetes.core.k8s_info: - kind: Secret - name: "{{ cifmw_openstack_lightspeed_llm_credentials | default('openstack-lightspeed-apitoken') }}" - namespace: "{{ openstack_lightspeed_namespace }}" - register: secret_check - failed_when: secret_check.resources | length == 0 - when: cifmw_openstack_lightspeed_api_token is not defined - no_log: true - - - name: Download TLS CA certificate from URL - ansible.builtin.uri: - url: "{{ cifmw_openstack_lightspeed_ca_cert_url }}" - return_content: true - register: ca_cert_download - when: cifmw_openstack_lightspeed_ca_cert_url is defined - - - name: Create TLS CA certificate bundle ConfigMap - kubernetes.core.k8s: - state: present - definition: - apiVersion: v1 - kind: ConfigMap - metadata: + register: secret_check + failed_when: secret_check.resources | length == 0 + when: cifmw_openstack_lightspeed_api_token is not defined + no_log: true + + - name: Download TLS CA certificate from URL + ansible.builtin.uri: + url: "{{ cifmw_openstack_lightspeed_ca_cert_url }}" + return_content: true + register: ca_cert_download + when: cifmw_openstack_lightspeed_ca_cert_url is defined + + - name: Create TLS CA certificate bundle ConfigMap + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: ConfigMap + metadata: + name: "{{ cifmw_openstack_lightspeed_tls_ca_cert_bundle | default('openstack-lightspeed-certs') }}" + namespace: "{{ openstack_lightspeed_namespace }}" + data: + ca-bundle.crt: >- + {{ ca_cert_download.content if cifmw_openstack_lightspeed_ca_cert_url is defined else cifmw_openstack_lightspeed_ca_cert }} + when: cifmw_openstack_lightspeed_ca_cert_url is defined or cifmw_openstack_lightspeed_ca_cert is defined + + - name: Verify TLS certificate bundle ConfigMap exists + kubernetes.core.k8s_info: + kind: ConfigMap name: "{{ cifmw_openstack_lightspeed_tls_ca_cert_bundle | default('openstack-lightspeed-certs') }}" namespace: "{{ openstack_lightspeed_namespace }}" - data: - ca-bundle.crt: >- - {{ ca_cert_download.content if cifmw_openstack_lightspeed_ca_cert_url is defined else cifmw_openstack_lightspeed_ca_cert }} - when: cifmw_openstack_lightspeed_ca_cert_url is defined or cifmw_openstack_lightspeed_ca_cert is defined - - - name: Verify TLS certificate bundle ConfigMap exists - kubernetes.core.k8s_info: - kind: ConfigMap - name: "{{ cifmw_openstack_lightspeed_tls_ca_cert_bundle | default('openstack-lightspeed-certs') }}" - namespace: "{{ openstack_lightspeed_namespace }}" - register: cert_check - failed_when: cert_check.resources | length == 0 - when: cifmw_openstack_lightspeed_ca_cert_url is defined or cifmw_openstack_lightspeed_ca_cert is defined - - - name: Create test-operator dummy config ConfigMap - kubernetes.core.k8s: - state: present - definition: - apiVersion: v1 - kind: ConfigMap - metadata: - name: "test-operator-dummy-config" - namespace: "{{ openstack_lightspeed_namespace }}" - labels: - app: "lightspeed-tests" - persistent: "true" - managed-by: "test-operator" - data: - clouds.yaml: | - # Dummy file - not used by lightspeed tests - # Required by test-operator but lightspeed tests don't need OpenStack - register: _result - retries: 5 - delay: 10 - until: _result is not failed - - - name: Create test-operator dummy secret - kubernetes.core.k8s: - state: present - definition: - apiVersion: v1 - kind: Secret - metadata: - name: "test-operator-dummy-secret" - namespace: "{{ openstack_lightspeed_namespace }}" - labels: - app: "lightspeed-tests" - persistent: "true" - managed-by: "test-operator" - type: Opaque - stringData: - secure.yaml: | - # Dummy file - not used by lightspeed tests - # Required by test-operator but lightspeed tests don't need OpenStack - register: _result - retries: 5 - delay: 10 - until: _result is not failed - - - name: Create dataplane-ansible-ssh-private-key-secret - kubernetes.core.k8s: - state: present - definition: - apiVersion: v1 - kind: Secret - metadata: - name: "dataplane-ansible-ssh-private-key-secret" - namespace: "{{ openstack_lightspeed_namespace }}" - labels: - app: "lightspeed-tests" - persistent: "true" - managed-by: "test-operator" - type: Opaque - stringData: - ssh-privatekey: | - # Dummy SSH key - not used by lightspeed tests - register: _result - retries: 5 - delay: 10 - until: _result is not failed - - # STEP 4: Create OpenStackLightspeed CR to deploy the service - # Note: Uses configurable parameters for LLM integration - - - name: Create OpenStackLightspeed custom resource - kubernetes.core.k8s: - state: present - definition: - apiVersion: lightspeed.openstack.org/v1beta1 - kind: OpenStackLightspeed - metadata: + register: cert_check + failed_when: cert_check.resources | length == 0 + when: cifmw_openstack_lightspeed_ca_cert_url is defined or cifmw_openstack_lightspeed_ca_cert is defined + + # STEP 4: Create OpenStackLightspeed CR to deploy the service + # Note: Uses configurable parameters for LLM integration + + - name: Create OpenStackLightspeed custom resource + kubernetes.core.k8s: + state: present + definition: + apiVersion: lightspeed.openstack.org/v1beta1 + kind: OpenStackLightspeed + metadata: + name: "{{ cifmw_openstack_lightspeed_cr_name | default('openstacklightspeed-sample') }}" + namespace: "{{ openstack_lightspeed_namespace }}" + spec: + defaultModel: default-model + models: + - name: default-model + llmEndpoint: "{{ cifmw_openstack_lightspeed_llm_endpoint }}" + llmEndpointType: "{{ cifmw_openstack_lightspeed_llm_endpoint_type | default('openai') }}" + modelName: "{{ cifmw_openstack_lightspeed_model_name }}" + llmCredentials: "{{ cifmw_openstack_lightspeed_llm_credentials | default('openstack-lightspeed-apitoken') }}" + tlsCACertBundle: "{{ cifmw_openstack_lightspeed_tls_ca_cert_bundle | default('openstack-lightspeed-certs') if (cifmw_openstack_lightspeed_ca_cert is defined + or cifmw_openstack_lightspeed_ca_cert_url is defined) else omit }}" + + - name: Wait for OpenStackLightspeed CR to be ready + kubernetes.core.k8s_info: + api_version: lightspeed.openstack.org/v1beta1 + kind: OpenStackLightspeed name: "{{ cifmw_openstack_lightspeed_cr_name | default('openstacklightspeed-sample') }}" namespace: "{{ openstack_lightspeed_namespace }}" - spec: - defaultModel: default-model - models: - - name: default-model - llmEndpoint: "{{ cifmw_openstack_lightspeed_llm_endpoint }}" - llmEndpointType: "{{ cifmw_openstack_lightspeed_llm_endpoint_type | default('openai') }}" - modelName: "{{ cifmw_openstack_lightspeed_model_name }}" - llmCredentials: "{{ cifmw_openstack_lightspeed_llm_credentials | default('openstack-lightspeed-apitoken') }}" - tlsCACertBundle: "{{ cifmw_openstack_lightspeed_tls_ca_cert_bundle | default('openstack-lightspeed-certs') if (cifmw_openstack_lightspeed_ca_cert is defined - or cifmw_openstack_lightspeed_ca_cert_url is defined) else omit }}" - - - name: Wait for OpenStackLightspeed CR to be ready - kubernetes.core.k8s_info: - api_version: lightspeed.openstack.org/v1beta1 - kind: OpenStackLightspeed - name: "{{ cifmw_openstack_lightspeed_cr_name | default('openstacklightspeed-sample') }}" - namespace: "{{ openstack_lightspeed_namespace }}" - register: cr_status - until: - - cr_status.resources is defined - - cr_status.resources | length > 0 - - cr_status.resources[0].status is defined - - cr_status.resources[0].status.conditions is defined - - cr_status.resources[0].status.conditions | selectattr('type', 'equalto', 'Ready') | map(attribute='status') | select('equalto', 'True') | list | - length > 0 - retries: "{{ cifmw_openstack_lightspeed_ready_retries | default(120) | int }}" - delay: 10 + register: cr_status + until: + - cr_status.resources is defined + - cr_status.resources | length > 0 + - cr_status.resources[0].status is defined + - cr_status.resources[0].status.conditions is defined + - cr_status.resources[0].status.conditions | selectattr('type', 'equalto', 'Ready') | map(attribute='status') | select('equalto', 'True') | list | + length > 0 + retries: "{{ cifmw_openstack_lightspeed_ready_retries | default(120) | int }}" + delay: 10 + rescue: + - name: Collect Lightspeed deployment diagnostics + ansible.builtin.command: + argv: + - oc + - get + - pods,deployments,events + - -n + - "{{ openstack_lightspeed_namespace }}" + - -o + - wide + register: lightspeed_diagnostics + changed_when: false + failed_when: false + + - name: Display Lightspeed deployment diagnostics + ansible.builtin.debug: + var: lightspeed_diagnostics.stdout_lines + + - name: Fail the Lightspeed deployment + ansible.builtin.fail: + msg: "OpenStack Lightspeed deployment failed; inspect the failed task and namespace diagnostics above." - name: Display deployment summary ansible.builtin.debug: diff --git a/hooks/playbooks/run-openstack-lightspeed-tests.yml b/hooks/playbooks/run-openstack-lightspeed-tests.yml new file mode 100644 index 000000000..a8219ebbc --- /dev/null +++ b/hooks/playbooks/run-openstack-lightspeed-tests.yml @@ -0,0 +1,144 @@ +--- +- name: Run OpenStack Lightspeed functional tests + hosts: "{{ cifmw_target_hook_host | default('localhost') }}" + connection: local + vars: + cifmw_lightspeed_tests_namespace: "{{ cifmw_openstack_lightspeed_namespace | default('openstack-lightspeed') }}" + cifmw_lightspeed_tests_source: "{{ ansible_env.HOME }}/src/github.com/openstack-k8s-operators/lightspeed-tests" + cifmw_lightspeed_tests_artifacts: "{{ cifmw_basedir | default(ansible_env.HOME ~ '/ci-framework-data') }}/tests/lightspeed" + cifmw_lightspeed_tests_timeout: 120 + cifmw_lightspeed_tests_kubeconfig: "{{ cifmw_openshift_kubeconfig | default(ansible_env.HOME ~ '/.crc/machines/crc/kubeconfig') }}" + environment: + KUBECONFIG: "{{ cifmw_lightspeed_tests_kubeconfig }}" + tasks: + - name: Require the prepared test checkout + ansible.builtin.stat: + path: "{{ cifmw_lightspeed_tests_source }}/playbooks/run_lightspeed_tests.yaml" + register: cifmw_lightspeed_tests_checkout + failed_when: not cifmw_lightspeed_tests_checkout.stat.exists + + - name: Create the artifact directory + ansible.builtin.file: + path: "{{ cifmw_lightspeed_tests_artifacts }}" + state: directory + mode: '0755' + + - name: Record the prepared test revision and local changes + ansible.builtin.shell: + cmd: | + git rev-parse HEAD + git status --short + git diff --stat + chdir: "{{ cifmw_lightspeed_tests_source }}" + register: cifmw_lightspeed_tests_revision + changed_when: false + + - name: Save test revision metadata + ansible.builtin.copy: + dest: "{{ cifmw_lightspeed_tests_artifacts }}/source-revision.txt" + content: "{{ cifmw_lightspeed_tests_revision.stdout }}\n" + mode: '0644' + + - name: Create an explicit Lightspeed client identity + kubernetes.core.k8s: + state: present + definition: + - apiVersion: v1 + kind: ServiceAccount + metadata: + name: lightspeed-tests + namespace: "{{ cifmw_lightspeed_tests_namespace }}" + - apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: lightspeed-tests-access + rules: + - nonResourceURLs: [/ls-access] + verbs: [get] + - apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: "lightspeed-tests-access-{{ cifmw_lightspeed_tests_namespace }}" + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: lightspeed-tests-access + subjects: + - kind: ServiceAccount + name: lightspeed-tests + namespace: "{{ cifmw_lightspeed_tests_namespace }}" + + - name: Create private temporary storage outside published artifacts + ansible.builtin.tempfile: + state: directory + prefix: lightspeed-tests- + register: cifmw_lightspeed_tests_private + + - name: Run tests through a temporary API tunnel + block: + - name: Issue a short lived client token + ansible.builtin.command: + argv: [oc, create, token, lightspeed-tests, -n, "{{ cifmw_lightspeed_tests_namespace }}", --duration=2h] + register: cifmw_lightspeed_tests_token + changed_when: false + no_log: true + + - name: Store the client token privately + ansible.builtin.copy: + dest: "{{ cifmw_lightspeed_tests_private.path }}/token" + content: "{{ cifmw_lightspeed_tests_token.stdout }}" + mode: '0600' + no_log: true + + - name: Select an unused local port + ansible.builtin.command: + argv: + - "{{ ansible_playbook_python }}" + - -c + - "import socket; s = socket.socket(); s.bind(('127.0.0.1', 0)); print(s.getsockname()[1]); s.close()" + register: cifmw_lightspeed_tests_port + changed_when: false + + - name: Start the private API tunnel + ansible.builtin.shell: | + echo $$ > {{ (cifmw_lightspeed_tests_private.path ~ '/tunnel.pid') | quote }} + exec oc port-forward -n {{ cifmw_lightspeed_tests_namespace | quote }} \ + service/lightspeed-app-server {{ cifmw_lightspeed_tests_port.stdout }}:8443 \ + --address=127.0.0.1 > {{ (cifmw_lightspeed_tests_artifacts ~ '/port-forward.log') | quote }} 2>&1 + async: 7200 + poll: 0 + register: cifmw_lightspeed_tests_tunnel + changed_when: true + + - name: Wait for the API tunnel + ansible.builtin.wait_for: + host: 127.0.0.1 + port: "{{ cifmw_lightspeed_tests_port.stdout | int }}" + timeout: 30 + + - name: Run integration tests against the deployed application + ansible.builtin.include_tasks: tasks/lightspeed-test-stage.yml + vars: + cifmw_lightspeed_tests_stage: baseline + cifmw_lightspeed_tests_pytest_args: "-v --tb=short --color=no tests/integration" + + always: + - name: Stop only the API tunnel started by this playbook + ansible.builtin.shell: | + if test -f {{ (cifmw_lightspeed_tests_private.path ~ '/tunnel.pid') | quote }}; then + kill -TERM "$(cat {{ (cifmw_lightspeed_tests_private.path ~ '/tunnel.pid') | quote }})" + fi + changed_when: true + failed_when: false + + - name: Remove the tunnel asynchronous task record + ansible.builtin.async_status: + jid: "{{ cifmw_lightspeed_tests_tunnel.ansible_job_id }}" + mode: cleanup + when: cifmw_lightspeed_tests_tunnel.ansible_job_id is defined + failed_when: false + + - name: Remove the private token and tunnel process record + ansible.builtin.file: + path: "{{ cifmw_lightspeed_tests_private.path }}" + state: absent diff --git a/hooks/playbooks/tasks/lightspeed-test-stage.yml b/hooks/playbooks/tasks/lightspeed-test-stage.yml new file mode 100644 index 000000000..69f991299 --- /dev/null +++ b/hooks/playbooks/tasks/lightspeed-test-stage.yml @@ -0,0 +1,43 @@ +--- +- name: Write runner inputs without embedding a client token + ansible.builtin.copy: + dest: "{{ cifmw_lightspeed_tests_private.path }}/runner-vars.yml" + mode: '0600' + content: >- + {{ {'test_suite_path': cifmw_lightspeed_tests_source, + 'lightspeed_url': 'https://127.0.0.1:' ~ cifmw_lightspeed_tests_port.stdout, + 'lightspeed_token_file': cifmw_lightspeed_tests_private.path ~ '/token', + 'lightspeed_timeout': cifmw_lightspeed_tests_timeout, + 'pytest_args': cifmw_lightspeed_tests_pytest_args, + 'junit_xml_path': cifmw_lightspeed_tests_artifacts ~ '/' ~ cifmw_lightspeed_tests_stage ~ '.xml'} | to_nice_yaml }} + +- name: Execute the Lightspeed test runner with a bounded runtime + ansible.builtin.command: + argv: + - timeout + - --kill-after=30s + - 1800s + - "{{ ansible_playbook_python | dirname }}/ansible-playbook" + - "{{ cifmw_lightspeed_tests_source }}/playbooks/run_lightspeed_tests.yaml" + - -i + - localhost, + - -e + - "@{{ cifmw_lightspeed_tests_private.path }}/runner-vars.yml" + register: cifmw_lightspeed_tests_result + changed_when: true + failed_when: false + +- name: Save the runner log even when tests fail + ansible.builtin.copy: + dest: "{{ cifmw_lightspeed_tests_artifacts }}/{{ cifmw_lightspeed_tests_stage }}-ansible.log" + content: "{{ cifmw_lightspeed_tests_result.stdout }}\n{{ cifmw_lightspeed_tests_result.stderr }}\nExit code: {{ cifmw_lightspeed_tests_result.rc }}\n" + mode: '0644' + +- name: Display the runner log + ansible.builtin.debug: + var: cifmw_lightspeed_tests_result.stdout_lines + +- name: Propagate a failed Lightspeed test run + ansible.builtin.assert: + that: cifmw_lightspeed_tests_result.rc == 0 + fail_msg: "Lightspeed {{ cifmw_lightspeed_tests_stage }} failed. See {{ cifmw_lightspeed_tests_artifacts }}." From 27ed3a1e4e067e56184c2008939baae2f25084b6 Mon Sep 17 00:00:00 2001 From: malingatembo Date: Fri, 9 Oct 2026 09:35:31 +0200 Subject: [PATCH 05/13] [ci] Add model discovery to the Lightspeed mock Serve GET /v1/models with the configured provider Related-Issue: OSPRH-33344 Signed-off-by: malingatembo --- hooks/playbooks/install-openstack-lightspeed.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/hooks/playbooks/install-openstack-lightspeed.yml b/hooks/playbooks/install-openstack-lightspeed.yml index defe10bdf..f9d27c260 100644 --- a/hooks/playbooks/install-openstack-lightspeed.yml +++ b/hooks/playbooks/install-openstack-lightspeed.yml @@ -146,6 +146,20 @@ from fastapi.responses import JSONResponse, StreamingResponse app = FastAPI() + mock_model = {{ cifmw_openstack_lightspeed_model_name | to_json }} + + @app.get("/v1/models") + async def models_get(): + return { + "object": "list", + "data": [{ + "id": mock_model, + "object": "model", + "created": 1234567890, + "owned_by": "mock", + }], + } + @app.post("/v1/chat/completions") async def completions_post(request: Request): try: @@ -155,7 +169,7 @@ if not isinstance(body, dict): body = {} - model = body.get("model") or "gpt-3.5-turbo" + model = body.get("model") or mock_model # The OpenAI API expects 'messages' (for chat) or 'prompt' (for completions), but for mock, we will accept either response = { From c369470a87e88a2a486cc7b6a760859bdee534e7 Mon Sep 17 00:00:00 2001 From: malingatembo Date: Sun, 11 Oct 2026 01:00:47 +0200 Subject: [PATCH 06/13] [docs] Add Lightspeed CI terms to spelling dictionary Recognize the project terms used in the Lightspeed testing guide. Related-Issue: #OSPRH-33344 Signed-off-by: malingatembo --- docs/dictionary/en-custom.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/dictionary/en-custom.txt b/docs/dictionary/en-custom.txt index 1756dd8d6..1f66c6c90 100644 --- a/docs/dictionary/en-custom.txt +++ b/docs/dictionary/en-custom.txt @@ -5,6 +5,7 @@ Amartya AssignedTeam CP CPython +CRC's ClusterServiceVersion FreeIPA IDM @@ -16,9 +17,11 @@ ITMS IMVHO IdP Idempotency +JUnit LDAP LLM LVs +Lightspeed MachineConfig Marjanovic MiB @@ -26,13 +29,19 @@ Nemanja NICs NodeHealthCheck NoValidHost +OKP +OpenStackLightspeed +PRs PV PyYAML +RBAC RHCOS SNO +ServiceAccount Sinha UTF ZipFile +Zuul's aaabbcc abcdefghij accesstoken @@ -94,6 +103,7 @@ bootmacaddress bootmode buildah buildpkgs +buildset cacert cacheable catalogsource @@ -673,6 +683,7 @@ undercloud unicast unittest unmanaged +unmerged unallocated uoyt uri From 87b8c5720dd350e8f3ceeb9ff783a67c685422f3 Mon Sep 17 00:00:00 2001 From: malingatembo Date: Sun, 11 Oct 2026 01:01:38 +0200 Subject: [PATCH 07/13] [test_operator] Run Lightspeed integration tests through test-operator Execute the prepared test source in an AnsibleTest Pod with explicit client authentication and PVC report collection. Replace the controller test hook and support stage-specific storage and security settings. Local checks passed. CRC validation is deferred; combined CI validation is pending. Reusing completed test resources remains a known limitation. Related-Issue: #OSPRH-33344 Signed-off-by: malingatembo --- docs/source/usage/lightspeed-testing.md | 227 ++++++++++-------- hooks/playbooks/files/lightspeed-source.py | 77 ++++++ hooks/playbooks/files/lightspeed-test-pod.yml | 89 +++++++ .../prepare-openstack-lightspeed-tests.yml | 161 +++++++++++++ .../run-openstack-lightspeed-tests.yml | 144 ----------- .../playbooks/tasks/lightspeed-test-stage.yml | 43 ---- roles/test_operator/README.md | 13 + roles/test_operator/defaults/main.yml | 8 +- .../tasks/run-test-operator-job.yml | 2 +- tests/unit/hooks/test_lightspeed_source.py | 110 +++++++++ tests/unit/roles/test_test_operator_stage.py | 104 ++++++++ 11 files changed, 693 insertions(+), 285 deletions(-) create mode 100644 hooks/playbooks/files/lightspeed-source.py create mode 100644 hooks/playbooks/files/lightspeed-test-pod.yml create mode 100644 hooks/playbooks/prepare-openstack-lightspeed-tests.yml delete mode 100644 hooks/playbooks/run-openstack-lightspeed-tests.yml delete mode 100644 hooks/playbooks/tasks/lightspeed-test-stage.yml create mode 100644 tests/unit/hooks/test_lightspeed_source.py create mode 100644 tests/unit/roles/test_test_operator_stage.py diff --git a/docs/source/usage/lightspeed-testing.md b/docs/source/usage/lightspeed-testing.md index 999a7f76f..1e7af8eea 100644 --- a/docs/source/usage/lightspeed-testing.md +++ b/docs/source/usage/lightspeed-testing.md @@ -1,104 +1,143 @@ # OpenStack Lightspeed deployment and functional tests -Use two ordered `pre_tests` hooks in the Lightspeed deployment job: +Deploy Lightspeed with the existing hook, then run its integration suite in an +AnsibleTest Pod through the `test_operator` role. The deployment job retains +its inherited Tempest stage: ```yaml pre_tests_01_deploy_lightspeed_operator: source: install-openstack-lightspeed.yml type: playbook -pre_tests_02_run_lightspeed_tests: - source: run-openstack-lightspeed-tests.yml - type: playbook cifmw_openstack_lightspeed_mock_enabled: true +cifmw_run_tests: true +cifmw_test_operator_fail_on_test_failure: true +cifmw_test_operator_stages: + - name: tempest + type: tempest + - name: lightspeed + type: ansibletest + pre_test_stage_hooks: + - name: Prepare Lightspeed tests + source: prepare-openstack-lightspeed-tests.yml + type: playbook + test_vars_file: "{{ cifmw_basedir }}/artifacts/lightspeed-test-stage.yml" +``` + +The stage list above preserves the default Tempest stage of the Lightspeed +job's parent. If another caller defines additional stages, retain those too. +The `cifmw_setup` role runs deployment hooks before entering the test role. +The preparation hook runs immediately before the Lightspeed stage. It creates +source and authorization resources and writes stage variables; the existing +role creates the AnsibleTest, waits for its Pods and collects the results. + +The deployment hook installs the catalog, OLM subscription, credential Secret +and OpenStackLightspeed resource. The opt-in mock handles model discovery and +ordinary or streamed completions. Readiness remains bounded by 120 retries at +ten-second intervals, configurable with +`cifmw_openstack_lightspeed_ready_retries`. + +## Source and authentication + +Require `openstack-k8s-operators/lightspeed-tests` in the job. While changes +remain unmerged, include both framework and tests PR URLs as `Depends-On:` lines +in the operator PR description. A required project alone does not select a PR. + +The preparation hook packages the tracked working files of the Zuul checkout, +including tracked local edits for isolated development. Files outside the index are +excluded; add new test files to the index before a manual run. The immutable +ConfigMap contains a compressed source archive, its revision metadata and a +small bootstrap playbook. A size check leaves room below the ConfigMap limit. +The Pod checks the expected SHA256 before extracting or executing this source. + +The current test image always clones a Git repository on startup. It therefore +still clones the public tests repository, but the absolute playbook path points +to the mounted bootstrap. That bootstrap runs the prepared source in a separate +writable directory. The public clone does not select the pytest revision. The +image also processes a root `requirements.yaml` if one is present in its clone; +that startup behavior and network access remain dependencies of the image. + +The test-operator disables automatic token mounting and currently uses the `default` +ServiceAccount in the test namespace. The stage explicitly projects that account's token +at `/lightspeed-auth/token`; Kubernetes rotates it. A `ClusterRoleBinding` grants +that identity GET access to `/ls-access`. This also grants that permission to +other Pods using the same account in the test namespace. The client token is +separate from the mock provider credential; neither its value nor its contents +are placed in stage variables, source archives or reports. + +Tests run in the existing test namespace, normally `openstack`, and call the +Lightspeed Service in `openstack-lightspeed` over the cluster network. The hook +requires the AnsibleTest CRD and the existing OpenStack ConfigMap, configuration +Secret and compute SSH Secret. It does not install test-operator or create +placeholder OpenStack resources. Those are supplied by the parent deployment. + +## Results and failures + +The prepared runner installs Python 3.12 and locked dependencies, then selects +`tests/integration`: metrics, OKP grounding, authenticated queries and missing +authentication. It writes `baseline.xml` and `baseline.xml.log` on the results +PVC. The bootstrap also retains `runner.log`, `runner-inputs.json` (paths only) +and `source-revision.json`. It requires the runner to succeed and the JUnit +report to contain executed tests with no failures or errors. Empty, missing, +malformed and entirely skipped reports fail the Pod. + +The role checks actual Pod phases, collects completed Pods' PVC contents even +when tests failed, and fails the job when a Pod failed or did not complete. +Completion on the last polling attempt is still collected. The Lightspeed reader +Pod uses user and group ID 227 and the same SELinux level as the test Pod so it can read +the PVC. These security settings apply only to the Lightspeed stage. The role's +usual timeout is 3600 seconds; this is a controller wait, not a Pod termination +deadline. Investigate unfinished Pods if that wait expires. + +The existing collector publishes under +`controller/ci-framework-data/tests/test_operator/`. Inspect the actual nested +paths for `baseline.xml`, its `.log` companion, `runner.log` and +`source-revision.json`. Compare the source revision with the Zuul workspace; +it can be a speculative merge commit. The generated AnsibleTest and reader +manifests are under `artifacts/test-operator-crs`, and preparation metadata is +under `artifacts/lightspeed-tests`. The preparation hook log is +`logs/pre_test_hooks_prepare_lightspeed_tests.log`. + +## Isolated role validation + +Reuse an already deployed, Ready Lightspeed instance and installed test-operator. +This avoids another complete OpenStack deployment. Supply a local prepared tests +checkout, existing OpenStack resource names and the cluster's storage class in +a variables file, for example: + +```yaml +cifmw_basedir: /home/cloud-user/ci-framework-data +cifmw_openshift_kubeconfig: /home/cloud-user/.crc/machines/crc/kubeconfig +cifmw_path: "{{ ansible_env.PATH }}" +cifmw_lightspeed_tests_source: /home/cloud-user/src/lightspeed-tests +cifmw_test_operator_namespace: openstack +cifmw_test_operator_storage_class: crc-csi-hostpath-provisioner +cifmw_test_operator_stages: + - name: lightspeed + type: ansibletest + test_vars_file: "{{ cifmw_basedir }}/artifacts/lightspeed-test-stage.yml" ``` -The job must require `openstack-k8s-operators/lightspeed-tests` so workspace -preparation supplies the tested revision on the controller. Include a -`Depends-On: ` in the consuming PR description -while test-runner changes remain unmerged. The hook uses the prepared checkout; -it does not clone an unrelated `main` branch. - -The deployment hook installs the catalog, OLM subscription, credential Secret, -and OpenStackLightspeed custom resource. Mock deployment is opt-in. It waits -for the mock Pod to be Ready, restarts that Pod after its code changes, and -allows up to 120 retries at ten-second intervals for application readiness. -Set `cifmw_openstack_lightspeed_ready_retries` to change that bounded wait. -Failure diagnostics include Pods, Deployments and events, followed by failure. - -The test hook creates a dedicated `lightspeed-tests` ServiceAccount and grants -only GET on `/ls-access`. It obtains a short-lived client token, stores it in a -private temporary directory, and forwards the application Service to an unused -loopback port. The client token is distinct from the provider API credential. -The wrapper reads the token file in Python; token contents are not runner vars -or published artifacts. The hook cleans up the token and its tunnel on failure -as well as success. RBAC objects remain available for subsequent runs. - -## Integration tests - -The `baseline` run selects `tests/integration`: authenticated queries, metrics, -OKP grounding and missing-authentication rejection. Configuration unit tests -are checked separately. The hook does not alter the provider Service. - -The hook invokes the Lightspeed repository's Ansible wrapper with a maximum -runtime of 1800 seconds. The wrapper uses Python 3.12 and locked dependencies, -saves pytest output, and requires pytest exit code zero and a JUnit report. -A test failure fails the wrapper, the hook and the calling job. The wrapper -does not independently validate XML contents or reject an entirely skipped -suite; inspect the reported test counts as part of CI acceptance. - -## Parameters and artifacts - -`cifmw_lightspeed_tests_source` defaults to -`~/src/github.com/openstack-k8s-operators/lightspeed-tests` on the controller. -Override it for a local checkout. `cifmw_openshift_kubeconfig` selects cluster -access, falling back to CRC's kubeconfig. The namespace is inherited from -`cifmw_openstack_lightspeed_namespace`, defaulting to `openstack-lightspeed`. -`cifmw_lightspeed_tests_timeout` controls individual API requests (default 120 -seconds). - -Artifacts default to `{{ cifmw_basedir }}/tests/lightspeed`: source revision, -port-forward output, baseline JUnit XML, pytest log and Ansible runner log. -Override `cifmw_lightspeed_tests_artifacts` for local testing. The existing -framework collector publishes its `tests` directory. Inspect the deployment -job's actual result as well as its reports when the job is non-voting. - -From the operator PR's Zuul bot comment, open the buildset, then -`lightspeed-operator-deployment-crc`, then its logs. Relative to that build's -log directory, look for: - -- `controller/ci-framework-data/logs/pre_tests_01_deploy_lightspeed_operator.log`: - installation and the Ready wait. -- `controller/ci-framework-data/logs/pre_tests_02_run_lightspeed_tests.log`: - authentication setup, API tunnel, test execution and cleanup. -- `controller/ci-framework-data/tests/lightspeed/baseline.xml`: individual test - cases and their results. -- `controller/ci-framework-data/tests/lightspeed/baseline.xml.log`: pytest output. -- `controller/ci-framework-data/tests/lightspeed/baseline-ansible.log`: dependency - setup, runner output and exit code, including setup failures. -- `controller/ci-framework-data/tests/lightspeed/source-revision.txt`: test checkout - revision and working-tree changes. Use `zuul-info/inventory.yaml` to identify - the PR revisions; Zuul's checkout HEAD may be a speculative merge commit. - -Accept the run only when the deployment job itself succeeds and the integration -report contains executed passing tests. A green buildset alone can conceal a -non-voting deployment failure. KUTTL is a separate job and suite. - -## Submitting the coordinated changes - -The test-runner changes need a `lightspeed-tests` PR as well as the framework -and operator PRs. Before rechecking the operator PR, put both the framework PR -URL and the actual test-runner PR URL in its description, each on its own -`Depends-On:` line. A `required-projects` entry supplies a checkout; it does not -by itself select an unmerged PR. Do not submit a placeholder dependency URL. - -This integration runs the existing suite on the CI controller through its -Ansible wrapper. It does not create an `AnsibleTest` resource or replace the -job's inherited test-operator stages. A future test-operator implementation -would need explicit token mounting and a way to use the prepared test revision. - -CRC validates the same hooks without waiting for a complete Zuul deployment. -It does not validate workspace preparation, content-provider image selection, -cross-repository dependencies or published artifact collection. A final combined -Zuul run is required. Mock results do not establish real-provider credentials, -TLS trust or model quality. The current test client's TLS verification remains -disabled for the disposable test environment. +First run the preparation hook with that file, then a local play containing +`roles: [test_operator]` with the same variables. These commands create cluster +resources; use them only when cluster execution is intended. The preparation +hook can be rerun safely for source packaging, but a completed AnsibleTest is +not automatically rerun by reapplying an identical resource. Preserve its +reports before removing that run's CR and owned resources for another attempt. + +`cifmw_lightspeed_tests_timeout` controls API request timeouts (120 seconds). +`cifmw_openstack_lightspeed_namespace` selects the application namespace; +`cifmw_lightspeed_tests_namespace` selects the test namespace, defaulting to +`cifmw_test_operator_namespace`. The existing AnsibleTest parameters select +OpenStack ConfigMap and Secret names. The stage uses the existing +`openstack-ansible-tests:current-podified` image and inherits the role's storage +class. It enables the same privileged setting as the standalone Lightspeed +example; privilege does not supply a token. + +Local syntax, source, manifest and simulated Pod checks do not establish +cluster execution. Accept the migration after a real Pod run publishes passing +integration cases and a deliberate failing run retains its reports and fails +the deployment job. A combined Zuul run must also verify image selection, +coordinated revisions and publication. Inspect the non-voting deployment job's +own result; a green buildset can conceal its failure. KUTTL is separate. Mock +results do not establish real-provider credentials, TLS trust or model quality; +the current test client disables TLS verification in this disposable setup. diff --git a/hooks/playbooks/files/lightspeed-source.py b/hooks/playbooks/files/lightspeed-source.py new file mode 100644 index 000000000..15bc0a7bc --- /dev/null +++ b/hooks/playbooks/files/lightspeed-source.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Package tracked working files from the Zuul checkout for an AnsibleTest Pod.""" + +import base64 +import gzip +import hashlib +import io +import json +from pathlib import Path +import subprocess +import sys +import tarfile + + +def package(source, bootstrap, destination): + source = Path(source).resolve() + destination = Path(destination) + + def git(*args): + return subprocess.check_output(["git", "-C", str(source), *args]) + + revision = git("rev-parse", "HEAD").decode().strip() + files = sorted(set(git("ls-files", "-z").decode().rstrip("\0").split("\0"))) + for required in ( + "playbooks/run_lightspeed_tests.yaml", + "pyproject.toml", + "uv.lock", + ): + if required not in files or not (source / required).is_file(): + raise ValueError(f"Missing tracked test input: {required}") + stream = io.BytesIO() + with gzip.GzipFile(fileobj=stream, mode="wb", mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode="w") as archive: + for name in files: + path = source / name + if not path.is_file() or path.is_symlink(): + raise ValueError(f"Expected a regular tracked file: {name}") + info = archive.gettarinfo(str(path), arcname=name) + info.uid = info.gid = info.mtime = 0 + info.uname = info.gname = "" + with path.open("rb") as content: + archive.addfile(info, content) + data = stream.getvalue() + bootstrap_text = Path(bootstrap).read_text() + # Leave room for metadata and the bootstrap within Kubernetes' 1 MiB limit. + if len(data) + len(bootstrap_text.encode()) > 900 * 1024: + raise ValueError("Lightspeed source exceeds the ConfigMap size budget") + checksum = hashlib.sha256(data).hexdigest() + metadata = { + "revision": revision, + "tracked_changes": git( + "status", "--porcelain", "--untracked-files=no" + ).decode(), + "sha256": checksum, + } + config = { + "apiVersion": "v1", + "kind": "ConfigMap", + "immutable": True, + "data": { + "run.yml": bootstrap_text, + "source-revision.json": json.dumps(metadata, indent=2) + "\n", + }, + "binaryData": {"source.tar.gz": base64.b64encode(data).decode()}, + } + identity = hashlib.sha256(json.dumps(config, sort_keys=True).encode()).hexdigest() + config["metadata"] = {"name": "lightspeed-tests-source-" + identity[:16]} + destination.mkdir(parents=True, exist_ok=True) + (destination / "source.tar.gz").write_bytes(data) + (destination / "source-revision.json").write_text( + json.dumps(metadata, indent=2) + "\n" + ) + (destination / "configmap.json").write_text(json.dumps(config, indent=2) + "\n") + + +if __name__ == "__main__": + package(*sys.argv[1:]) diff --git a/hooks/playbooks/files/lightspeed-test-pod.yml b/hooks/playbooks/files/lightspeed-test-pod.yml new file mode 100644 index 000000000..0d14f8eea --- /dev/null +++ b/hooks/playbooks/files/lightspeed-test-pod.yml @@ -0,0 +1,89 @@ +--- +- name: Run the prepared Lightspeed source inside the test Pod + hosts: localhost + connection: local + gather_facts: true + vars: + cifmw_lightspeed_tests_mount: /lightspeed-source + cifmw_lightspeed_tests_checkout: /var/lib/ansible/lightspeed-tests + cifmw_lightspeed_tests_results: /var/lib/AnsibleTests/external_files + tasks: + - name: Check the mounted source archive + ansible.builtin.stat: + path: "{{ cifmw_lightspeed_tests_mount }}/source.tar.gz" + follow: true + checksum_algorithm: sha256 + register: cifmw_lightspeed_tests_archive + failed_when: cifmw_lightspeed_tests_archive.stat.checksum | default('') != cifmw_lightspeed_tests_sha256 + + - name: Create a writable test checkout + ansible.builtin.file: + path: "{{ cifmw_lightspeed_tests_checkout }}" + state: directory + mode: '0755' + + - name: Extract the prepared test source + ansible.builtin.unarchive: + src: "{{ cifmw_lightspeed_tests_mount }}/source.tar.gz" + dest: "{{ cifmw_lightspeed_tests_checkout }}" + remote_src: true + + - name: Preserve the source identity in the results PVC + ansible.builtin.copy: + src: "{{ cifmw_lightspeed_tests_mount }}/source-revision.json" + dest: "{{ cifmw_lightspeed_tests_results }}/source-revision.json" + remote_src: true + mode: '0644' + + - name: Write runner inputs containing only the token path + ansible.builtin.copy: + content: "{{ cifmw_lightspeed_tests_inputs | to_nice_json }}" + dest: "{{ cifmw_lightspeed_tests_results }}/runner-inputs.json" + mode: '0644' + + - name: Execute the prepared Lightspeed runner + ansible.builtin.command: + argv: + - ansible-playbook + - "{{ cifmw_lightspeed_tests_checkout }}/playbooks/run_lightspeed_tests.yaml" + - -i + - localhost, + - -e + - "@{{ cifmw_lightspeed_tests_results }}/runner-inputs.json" + register: cifmw_lightspeed_tests_run + changed_when: true + failed_when: false + + - name: Preserve runner output even when setup or tests fail + ansible.builtin.copy: + dest: "{{ cifmw_lightspeed_tests_results }}/runner.log" + content: | + {{ cifmw_lightspeed_tests_run.stdout | default('') }} + {{ cifmw_lightspeed_tests_run.stderr | default('') }} + {{ cifmw_lightspeed_tests_run.msg | default('') }} + Exit code: {{ cifmw_lightspeed_tests_run.rc | default(-1) }} + mode: '0644' + + - name: Require an executed passing integration report + ansible.builtin.command: + argv: + - "{{ ansible_playbook_python }}" + - -c + - | + import json, sys, xml.etree.ElementTree as ET + cases = list(ET.parse(sys.argv[1]).iter('testcase')) + skipped = sum(case.find('skipped') is not None for case in cases) + failed = sum(case.find('failure') is not None or case.find('error') is not None for case in cases) + print(json.dumps({'tests': len(cases), 'skipped': skipped, 'failed': failed})) + sys.exit(0 if len(cases) > skipped and failed == 0 else 1) + - "{{ cifmw_lightspeed_tests_inputs.junit_xml_path }}" + register: cifmw_lightspeed_tests_report + changed_when: false + failed_when: false + + - name: Propagate runner and report failures to the test Pod + ansible.builtin.assert: + that: + - cifmw_lightspeed_tests_run.rc | default(-1) == 0 + - cifmw_lightspeed_tests_report.rc == 0 + fail_msg: "Lightspeed tests failed or produced no executed passing tests. Inspect runner.log and baseline.xml on the results PVC." diff --git a/hooks/playbooks/prepare-openstack-lightspeed-tests.yml b/hooks/playbooks/prepare-openstack-lightspeed-tests.yml new file mode 100644 index 000000000..ce2eebc58 --- /dev/null +++ b/hooks/playbooks/prepare-openstack-lightspeed-tests.yml @@ -0,0 +1,161 @@ +--- +- name: Prepare OpenStack Lightspeed for the test operator stage + hosts: "{{ cifmw_target_hook_host | default('localhost') }}" + connection: local + vars: + cifmw_lightspeed_tests_namespace: "{{ cifmw_test_operator_namespace | default('openstack') }}" + cifmw_lightspeed_tests_source: "{{ ansible_env.HOME }}/src/github.com/openstack-k8s-operators/lightspeed-tests" + cifmw_lightspeed_tests_artifacts: "{{ cifmw_basedir }}/artifacts/lightspeed-tests" + cifmw_lightspeed_tests_timeout: 120 + cifmw_lightspeed_tests_kubeconfig: "{{ cifmw_openshift_kubeconfig | default(ansible_env.HOME ~ '/.crc/machines/crc/kubeconfig') }}" + cifmw_lightspeed_tests_selinux_level: "{{ cifmw_test_operator_selinux_level | default('s0:c478,c978') }}" + environment: + KUBECONFIG: "{{ cifmw_lightspeed_tests_kubeconfig }}" + tasks: + - name: Require the installed AnsibleTest API + kubernetes.core.k8s_info: + api_version: apiextensions.k8s.io/v1 + kind: CustomResourceDefinition + name: ansibletests.test.openstack.org + register: cifmw_lightspeed_tests_api + failed_when: cifmw_lightspeed_tests_api.resources | length != 1 + + - name: Check the existing OpenStack configuration and SSH resources + kubernetes.core.k8s_info: + kind: "{{ item.kind }}" + name: "{{ item.name }}" + namespace: "{{ cifmw_lightspeed_tests_namespace }}" + register: cifmw_lightspeed_tests_prerequisite + failed_when: cifmw_lightspeed_tests_prerequisite.resources | length != 1 + # k8s_info returns Secret data; never include it in the hook log. + no_log: true + loop: + - kind: ConfigMap + name: "{{ cifmw_test_operator_ansibletest_openstack_config_map | default('openstack-config') }}" + - kind: Secret + name: "{{ cifmw_test_operator_ansibletest_openstack_config_secret | default('openstack-config-secret') }}" + - kind: Secret + name: "{{ cifmw_test_operator_ansibletest_compute_ssh_key_secret_name | default('dataplane-ansible-ssh-private-key-secret') }}" + + - name: Create the source artifact directory + ansible.builtin.file: + path: "{{ cifmw_lightspeed_tests_artifacts }}" + state: directory + mode: '0755' + + - name: Package the tracked working files from the prepared checkout + ansible.builtin.command: + argv: + - "{{ ansible_playbook_python }}" + - "{{ playbook_dir }}/files/lightspeed-source.py" + - "{{ cifmw_lightspeed_tests_source }}" + - "{{ playbook_dir }}/files/lightspeed-test-pod.yml" + - "{{ cifmw_lightspeed_tests_artifacts }}" + changed_when: true + + - name: Read the packaged source definition and identity + ansible.builtin.set_fact: + cifmw_lightspeed_tests_configmap: "{{ lookup('file', cifmw_lightspeed_tests_artifacts ~ '/configmap.json') | from_json }}" + cifmw_lightspeed_tests_identity: "{{ lookup('file', cifmw_lightspeed_tests_artifacts ~ '/source-revision.json') | from_json }}" + no_log: true + + - name: Mount the immutable prepared source for the test Pod + kubernetes.core.k8s: + state: present + namespace: "{{ cifmw_lightspeed_tests_namespace }}" + definition: "{{ cifmw_lightspeed_tests_configmap }}" + no_log: true + + - name: Authorize the test Pod's explicitly projected client token + kubernetes.core.k8s: + state: present + definition: + - apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: lightspeed-tests-access + rules: + - nonResourceURLs: [/ls-access] + verbs: [get] + - apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: "lightspeed-tests-access-{{ cifmw_lightspeed_tests_namespace }}" + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: lightspeed-tests-access + subjects: + # The current test-operator uses the namespace's default account. + - kind: ServiceAccount + name: default + namespace: "{{ cifmw_lightspeed_tests_namespace }}" + + - name: Write the Lightspeed stage variables for the existing test operator role + vars: + cifmw_lightspeed_tests_stage_vars: + cifmw_test_operator_namespace: "{{ cifmw_lightspeed_tests_namespace }}" + cifmw_test_operator_privileged: true + cifmw_test_operator_selinux_level: "{{ cifmw_lightspeed_tests_selinux_level }}" + cifmw_test_operator_ansibletest_name: ansibletest + cifmw_test_operator_ansibletest_image: quay.io/podified-antelope-centos9/openstack-ansible-tests + cifmw_test_operator_ansibletest_image_tag: current-podified + # The image clones this repository as part of its entrypoint. The mounted + # playbook below executes only the checksum-verified prepared source. + cifmw_test_operator_ansibletest_ansible_git_repo: https://github.com/openstack-k8s-operators/lightspeed-tests + cifmw_test_operator_ansibletest_ansible_git_branch: "" + cifmw_test_operator_ansibletest_ansible_playbook_path: /lightspeed-source/run.yml + cifmw_test_operator_ansibletest_ansible_inventory: | + localhost ansible_connection=local ansible_python_interpreter=python3 + cifmw_test_operator_ansibletest_debug: false + cifmw_test_operator_ansibletest_resources: + requests: + cpu: 1000m + memory: 1Gi + limits: + cpu: 2000m + memory: 2Gi + cifmw_test_operator_ansibletest_ansible_var_files: "{{ cifmw_lightspeed_tests_pod_vars | to_nice_yaml }}" + cifmw_test_operator_ansibletest_extra_mounts: + - name: lightspeed + extraVol: + - propagation: [AnsibleTest] + extraVolType: Undefined + volumes: + - name: lightspeed-source + configMap: + name: "{{ cifmw_lightspeed_tests_configmap.metadata.name }}" + - name: lightspeed-client-token + projected: + sources: + - serviceAccountToken: + path: token + expirationSeconds: 3600 + mounts: + - name: lightspeed-source + mountPath: /lightspeed-source + readOnly: true + - name: lightspeed-client-token + mountPath: /lightspeed-auth + readOnly: true + # Match the test Pod's UID and SELinux level to read its results PVC. + # Keep these changes local to this stage, including the reader Pod. + cifmw_test_operator_log_pod_security_context: + runAsUser: 227 + runAsGroup: 227 + fsGroup: 227 + seLinuxOptions: + level: "{{ cifmw_lightspeed_tests_selinux_level }}" + cifmw_lightspeed_tests_pod_vars: + cifmw_lightspeed_tests_sha256: "{{ cifmw_lightspeed_tests_identity.sha256 }}" + cifmw_lightspeed_tests_inputs: + test_suite_path: /var/lib/ansible/lightspeed-tests + lightspeed_url: "https://lightspeed-app-server.{{ cifmw_openstack_lightspeed_namespace | default('openstack-lightspeed') }}.svc.cluster.local:8443" + lightspeed_timeout: "{{ cifmw_lightspeed_tests_timeout | int }}" + lightspeed_token_file: /lightspeed-auth/token + pytest_args: "-v --tb=short --color=no tests/integration" + junit_xml_path: /var/lib/AnsibleTests/external_files/baseline.xml + ansible.builtin.copy: + dest: "{{ cifmw_basedir }}/artifacts/lightspeed-test-stage.yml" + content: "{{ cifmw_lightspeed_tests_stage_vars | to_nice_yaml }}" + mode: '0644' diff --git a/hooks/playbooks/run-openstack-lightspeed-tests.yml b/hooks/playbooks/run-openstack-lightspeed-tests.yml deleted file mode 100644 index a8219ebbc..000000000 --- a/hooks/playbooks/run-openstack-lightspeed-tests.yml +++ /dev/null @@ -1,144 +0,0 @@ ---- -- name: Run OpenStack Lightspeed functional tests - hosts: "{{ cifmw_target_hook_host | default('localhost') }}" - connection: local - vars: - cifmw_lightspeed_tests_namespace: "{{ cifmw_openstack_lightspeed_namespace | default('openstack-lightspeed') }}" - cifmw_lightspeed_tests_source: "{{ ansible_env.HOME }}/src/github.com/openstack-k8s-operators/lightspeed-tests" - cifmw_lightspeed_tests_artifacts: "{{ cifmw_basedir | default(ansible_env.HOME ~ '/ci-framework-data') }}/tests/lightspeed" - cifmw_lightspeed_tests_timeout: 120 - cifmw_lightspeed_tests_kubeconfig: "{{ cifmw_openshift_kubeconfig | default(ansible_env.HOME ~ '/.crc/machines/crc/kubeconfig') }}" - environment: - KUBECONFIG: "{{ cifmw_lightspeed_tests_kubeconfig }}" - tasks: - - name: Require the prepared test checkout - ansible.builtin.stat: - path: "{{ cifmw_lightspeed_tests_source }}/playbooks/run_lightspeed_tests.yaml" - register: cifmw_lightspeed_tests_checkout - failed_when: not cifmw_lightspeed_tests_checkout.stat.exists - - - name: Create the artifact directory - ansible.builtin.file: - path: "{{ cifmw_lightspeed_tests_artifacts }}" - state: directory - mode: '0755' - - - name: Record the prepared test revision and local changes - ansible.builtin.shell: - cmd: | - git rev-parse HEAD - git status --short - git diff --stat - chdir: "{{ cifmw_lightspeed_tests_source }}" - register: cifmw_lightspeed_tests_revision - changed_when: false - - - name: Save test revision metadata - ansible.builtin.copy: - dest: "{{ cifmw_lightspeed_tests_artifacts }}/source-revision.txt" - content: "{{ cifmw_lightspeed_tests_revision.stdout }}\n" - mode: '0644' - - - name: Create an explicit Lightspeed client identity - kubernetes.core.k8s: - state: present - definition: - - apiVersion: v1 - kind: ServiceAccount - metadata: - name: lightspeed-tests - namespace: "{{ cifmw_lightspeed_tests_namespace }}" - - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRole - metadata: - name: lightspeed-tests-access - rules: - - nonResourceURLs: [/ls-access] - verbs: [get] - - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRoleBinding - metadata: - name: "lightspeed-tests-access-{{ cifmw_lightspeed_tests_namespace }}" - roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: lightspeed-tests-access - subjects: - - kind: ServiceAccount - name: lightspeed-tests - namespace: "{{ cifmw_lightspeed_tests_namespace }}" - - - name: Create private temporary storage outside published artifacts - ansible.builtin.tempfile: - state: directory - prefix: lightspeed-tests- - register: cifmw_lightspeed_tests_private - - - name: Run tests through a temporary API tunnel - block: - - name: Issue a short lived client token - ansible.builtin.command: - argv: [oc, create, token, lightspeed-tests, -n, "{{ cifmw_lightspeed_tests_namespace }}", --duration=2h] - register: cifmw_lightspeed_tests_token - changed_when: false - no_log: true - - - name: Store the client token privately - ansible.builtin.copy: - dest: "{{ cifmw_lightspeed_tests_private.path }}/token" - content: "{{ cifmw_lightspeed_tests_token.stdout }}" - mode: '0600' - no_log: true - - - name: Select an unused local port - ansible.builtin.command: - argv: - - "{{ ansible_playbook_python }}" - - -c - - "import socket; s = socket.socket(); s.bind(('127.0.0.1', 0)); print(s.getsockname()[1]); s.close()" - register: cifmw_lightspeed_tests_port - changed_when: false - - - name: Start the private API tunnel - ansible.builtin.shell: | - echo $$ > {{ (cifmw_lightspeed_tests_private.path ~ '/tunnel.pid') | quote }} - exec oc port-forward -n {{ cifmw_lightspeed_tests_namespace | quote }} \ - service/lightspeed-app-server {{ cifmw_lightspeed_tests_port.stdout }}:8443 \ - --address=127.0.0.1 > {{ (cifmw_lightspeed_tests_artifacts ~ '/port-forward.log') | quote }} 2>&1 - async: 7200 - poll: 0 - register: cifmw_lightspeed_tests_tunnel - changed_when: true - - - name: Wait for the API tunnel - ansible.builtin.wait_for: - host: 127.0.0.1 - port: "{{ cifmw_lightspeed_tests_port.stdout | int }}" - timeout: 30 - - - name: Run integration tests against the deployed application - ansible.builtin.include_tasks: tasks/lightspeed-test-stage.yml - vars: - cifmw_lightspeed_tests_stage: baseline - cifmw_lightspeed_tests_pytest_args: "-v --tb=short --color=no tests/integration" - - always: - - name: Stop only the API tunnel started by this playbook - ansible.builtin.shell: | - if test -f {{ (cifmw_lightspeed_tests_private.path ~ '/tunnel.pid') | quote }}; then - kill -TERM "$(cat {{ (cifmw_lightspeed_tests_private.path ~ '/tunnel.pid') | quote }})" - fi - changed_when: true - failed_when: false - - - name: Remove the tunnel asynchronous task record - ansible.builtin.async_status: - jid: "{{ cifmw_lightspeed_tests_tunnel.ansible_job_id }}" - mode: cleanup - when: cifmw_lightspeed_tests_tunnel.ansible_job_id is defined - failed_when: false - - - name: Remove the private token and tunnel process record - ansible.builtin.file: - path: "{{ cifmw_lightspeed_tests_private.path }}" - state: absent diff --git a/hooks/playbooks/tasks/lightspeed-test-stage.yml b/hooks/playbooks/tasks/lightspeed-test-stage.yml deleted file mode 100644 index 69f991299..000000000 --- a/hooks/playbooks/tasks/lightspeed-test-stage.yml +++ /dev/null @@ -1,43 +0,0 @@ ---- -- name: Write runner inputs without embedding a client token - ansible.builtin.copy: - dest: "{{ cifmw_lightspeed_tests_private.path }}/runner-vars.yml" - mode: '0600' - content: >- - {{ {'test_suite_path': cifmw_lightspeed_tests_source, - 'lightspeed_url': 'https://127.0.0.1:' ~ cifmw_lightspeed_tests_port.stdout, - 'lightspeed_token_file': cifmw_lightspeed_tests_private.path ~ '/token', - 'lightspeed_timeout': cifmw_lightspeed_tests_timeout, - 'pytest_args': cifmw_lightspeed_tests_pytest_args, - 'junit_xml_path': cifmw_lightspeed_tests_artifacts ~ '/' ~ cifmw_lightspeed_tests_stage ~ '.xml'} | to_nice_yaml }} - -- name: Execute the Lightspeed test runner with a bounded runtime - ansible.builtin.command: - argv: - - timeout - - --kill-after=30s - - 1800s - - "{{ ansible_playbook_python | dirname }}/ansible-playbook" - - "{{ cifmw_lightspeed_tests_source }}/playbooks/run_lightspeed_tests.yaml" - - -i - - localhost, - - -e - - "@{{ cifmw_lightspeed_tests_private.path }}/runner-vars.yml" - register: cifmw_lightspeed_tests_result - changed_when: true - failed_when: false - -- name: Save the runner log even when tests fail - ansible.builtin.copy: - dest: "{{ cifmw_lightspeed_tests_artifacts }}/{{ cifmw_lightspeed_tests_stage }}-ansible.log" - content: "{{ cifmw_lightspeed_tests_result.stdout }}\n{{ cifmw_lightspeed_tests_result.stderr }}\nExit code: {{ cifmw_lightspeed_tests_result.rc }}\n" - mode: '0644' - -- name: Display the runner log - ansible.builtin.debug: - var: cifmw_lightspeed_tests_result.stdout_lines - -- name: Propagate a failed Lightspeed test run - ansible.builtin.assert: - that: cifmw_lightspeed_tests_result.rc == 0 - fail_msg: "Lightspeed {{ cifmw_lightspeed_tests_stage }} failed. See {{ cifmw_lightspeed_tests_artifacts }}." diff --git a/roles/test_operator/README.md b/roles/test_operator/README.md index cc508f00a..9155ee465 100644 --- a/roles/test_operator/README.md +++ b/roles/test_operator/README.md @@ -23,6 +23,7 @@ Execute tests via the [test-operator](https://openstack-k8s-operators.github.io/ * `cifmw_test_operator_fail_on_test_failure`: (Boolean) Whether the role should fail on any test failure or not. Default value: `true` * `cifmw_test_operator_log_pod_definition`: (Object) The CR definition template for creating the test log pod. Default value: See defaults in [main.yml](https://github.com/openstack-k8s-operators/ci-framework/blob/main/roles/test_operator/defaults/main.yml) * `cifmw_test_operator_logs_image`: (String) Image that should be used to collect logs from the pods spawned by the test-operator. Default value: `quay.io/quay/busybox` +* `cifmw_test_operator_log_pod_security_context`: (Dict) Security context for the results reader Pod, with stage overrides supported. Default value: `{}` * `cifmw_test_operator_namespace`: (String) Namespace inside which all the resources are created. Default value: `openstack` * `cifmw_test_operator_node_selector`: (Dict) `nodeSelector` value that is applied to all pods spawned by the test-operator and to the test-operator-controller-manager and test-operator-logs pods. Default value: `{}` * `cifmw_test_operator_privileged`: (Boolean) Spawn the test pods with `allowPrivilegedEscalation: true` and default linux capabilities. This is required for certain test-operator functionalities to work properly (e.g.: `extraRPMs`, certain set of tobiko tests). Default value: `false` @@ -196,3 +197,15 @@ cifmw_test_operator_stages: type: tobiko test_vars_file: /path/to/tobiko/override/test/file ``` + +## AnsibleTest stage overrides + +The AnsibleTest manifest uses stage-specific `cifmw_test_operator_privileged`, +`cifmw_test_operator_selinux_level` and `cifmw_test_operator_storage_class` +values when provided in `test_vars` or `test_vars_file`. The collector also +uses the stage's `cifmw_test_operator_log_pod_security_context` (default `{}`). This lets a stage +match the reader Pod's security context to the test Pod without changing +other stages. With no stage override the existing global values apply. + +A terminal Pod phase on the final polling attempt is completion, not a timeout; +its logs are collected before the normal success/failure decision. diff --git a/roles/test_operator/defaults/main.yml b/roles/test_operator/defaults/main.yml index 49f0adcd4..95a357a61 100644 --- a/roles/test_operator/defaults/main.yml +++ b/roles/test_operator/defaults/main.yml @@ -43,6 +43,7 @@ cifmw_test_operator_delete_logs_pod: false cifmw_test_operator_privileged: false cifmw_test_operator_selinux_level: "s0:c478,c978" cifmw_test_operator_crs_path: "{{ cifmw_basedir }}/artifacts/test-operator-crs" +cifmw_test_operator_log_pod_security_context: {} cifmw_test_operator_log_pod_definition: apiVersion: v1 kind: Pod @@ -50,6 +51,7 @@ cifmw_test_operator_log_pod_definition: name: "test-operator-logs-pod-{{ run_test_fw }}-{{ test_operator_instance_name }}" namespace: "{{ stage_vars_dict.cifmw_test_operator_namespace }}" spec: + securityContext: "{{ stage_vars_dict.cifmw_test_operator_log_pod_security_context | default({}) or omit }}" containers: - name: test-operator-logs-container image: "{{ cifmw_test_operator_logs_image }}" @@ -261,11 +263,11 @@ cifmw_test_operator_ansibletest_config: name: "{{ stage_vars_dict.cifmw_test_operator_ansibletest_name }}-{{ _stage_vars.name }}" namespace: "{{ stage_vars_dict.cifmw_test_operator_namespace }}" spec: - SELinuxLevel: "{{ cifmw_test_operator_selinux_level }}" + SELinuxLevel: "{{ stage_vars_dict.cifmw_test_operator_selinux_level }}" containerImage: "{{ stage_vars_dict.cifmw_test_operator_ansibletest_image }}:{{ stage_vars_dict.cifmw_test_operator_ansibletest_image_tag }}" extraMounts: "{{ stage_vars_dict.cifmw_test_operator_ansibletest_extra_mounts | default(omit) }}" - storageClass: "{{ cifmw_test_operator_storage_class }}" - privileged: "{{ cifmw_test_operator_privileged }}" + storageClass: "{{ stage_vars_dict.cifmw_test_operator_storage_class }}" + privileged: "{{ stage_vars_dict.cifmw_test_operator_privileged }}" computeSSHKeySecretName: "{{ stage_vars_dict.cifmw_test_operator_ansibletest_compute_ssh_key_secret_name }}" workloadSSHKeySecretName: "{{ stage_vars_dict.cifmw_test_operator_ansibletest_workload_ssh_key_secret_name }}" ansibleGitRepo: "{{ stage_vars_dict.cifmw_test_operator_ansibletest_ansible_git_repo }}" diff --git a/roles/test_operator/tasks/run-test-operator-job.yml b/roles/test_operator/tasks/run-test-operator-job.yml index bab2d7837..b15b721b3 100644 --- a/roles/test_operator/tasks/run-test-operator-job.yml +++ b/roles/test_operator/tasks/run-test-operator-job.yml @@ -72,7 +72,7 @@ - name: Check whether timed out - {{ run_test_fw }} ansible.builtin.set_fact: testpod_timed_out: >- - {{ testpod.attempts == (cifmw_test_operator_timeout / 10) | round | int }} + {{ testpod.resources[0].status.phase | default('') not in ['Succeeded', 'Failed'] }} - name: Collect logs when: diff --git a/tests/unit/hooks/test_lightspeed_source.py b/tests/unit/hooks/test_lightspeed_source.py new file mode 100644 index 000000000..857247c16 --- /dev/null +++ b/tests/unit/hooks/test_lightspeed_source.py @@ -0,0 +1,110 @@ +"""Source transport contracts; no network or Kubernetes access required.""" + +import base64 +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import tarfile +import tempfile +import unittest + +SCRIPT = ( + Path(__file__).resolve().parents[3] / "hooks/playbooks/files/lightspeed-source.py" +) +spec = importlib.util.spec_from_file_location("lightspeed_source", SCRIPT) +source_module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(source_module) + + +class TestSourceArchive(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.repo = self.root / "repo" + self.repo.mkdir() + self.git("init", "-q") + for name in [ + "playbooks/run_lightspeed_tests.yaml", + "pyproject.toml", + "uv.lock", + ]: + path = self.repo / name + path.parent.mkdir(exist_ok=True) + path.write_text("initial\n") + self.git("add", ".") + self.git( + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "Fixture baseline", + ) + self.bootstrap = self.root / "run.yml" + self.bootstrap.write_text("---\n[]\n") + self.output = self.root / "output" + + def git(self, *args): + return subprocess.check_output(["git", "-C", str(self.repo), *args]) + + def package(self): + source_module.package(self.repo, self.bootstrap, self.output) + return json.loads((self.output / "configmap.json").read_text()) + + def test_working_edits_and_indexed_additions_are_used(self): + (self.repo / "uv.lock").write_text("tracked local edit\n") + (self.repo / "added.py").write_text("new source\n") + self.git("add", "added.py") + (self.repo / "untracked-token").write_text("never archive this") + config = self.package() + archive_data = base64.b64decode(config["binaryData"]["source.tar.gz"]) + with tarfile.open(fileobj=io.BytesIO(archive_data), mode="r:gz") as archive: + self.assertEqual( + archive.extractfile("uv.lock").read(), b"tracked local edit\n" + ) + self.assertIn("added.py", archive.getnames()) + self.assertNotIn("untracked-token", archive.getnames()) + metadata = json.loads(config["data"]["source-revision.json"]) + self.assertEqual(metadata["sha256"], hashlib.sha256(archive_data).hexdigest()) + self.assertEqual( + metadata["revision"], self.git("rev-parse", "HEAD").decode().strip() + ) + self.assertIn("uv.lock", metadata["tracked_changes"]) + + def test_identical_inputs_produce_identical_configmap(self): + before = self.package() + os.utime(self.repo / "uv.lock", (100, 100)) + self.assertEqual(before, self.package()) + self.bootstrap.write_text("---\n- name: Updated bootstrap\n") + self.assertNotEqual( + before["metadata"]["name"], self.package()["metadata"]["name"] + ) + + def test_missing_required_input_is_rejected(self): + (self.repo / "uv.lock").unlink() + with self.assertRaisesRegex(ValueError, "Missing tracked test input"): + self.package() + + def test_symlink_is_not_followed_into_private_files(self): + (self.repo / "linked").symlink_to(self.bootstrap) + self.git("add", "linked") + with self.assertRaisesRegex(ValueError, "regular tracked file"): + self.package() + + def test_oversize_configmap_is_rejected(self): + (self.repo / "large").write_bytes(os.urandom(1024 * 1024)) + self.git("add", "large") + with self.assertRaisesRegex(ValueError, "size budget"): + self.package() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/roles/test_test_operator_stage.py b/tests/unit/roles/test_test_operator_stage.py new file mode 100644 index 000000000..202a12917 --- /dev/null +++ b/tests/unit/roles/test_test_operator_stage.py @@ -0,0 +1,104 @@ +"""Render stage settings without connecting to a Kubernetes cluster.""" + +from copy import deepcopy +from pathlib import Path +import unittest + +from ansible.parsing.dataloader import DataLoader +from ansible.template import Templar +import yaml + +ROLE = Path(__file__).resolve().parents[3] / "roles/test_operator" + + +class TestStageSettings(unittest.TestCase): + def setUp(self): + self.defaults = yaml.safe_load((ROLE / "defaults/main.yml").read_text()) + + def render(self, variable, overrides=None, **extra): + stage = { + key: deepcopy(self.defaults[key]) + for key in ( + "cifmw_test_operator_namespace", + "cifmw_test_operator_privileged", + "cifmw_test_operator_storage_class", + "cifmw_test_operator_selinux_level", + "cifmw_test_operator_log_pod_security_context", + ) + } + stage.update(overrides or {}) + variables = dict(self.defaults) + variables.update( + stage_vars_dict=stage, + _stage_vars={"name": "fixture"}, + cifmw_default_registry="quay.io", + cifmw_default_container_image_namespace="example", + cifmw_default_container_image_tag="current", + run_test_fw="ansibletest", + test_operator_instance_name="ansibletest-fixture", + _test_operator_volume_mounts=[], + _test_operator_volumes=[], + ) + variables.update(extra) + return Templar(loader=DataLoader(), variables=variables).template(variable) + + def test_ansibletest_stage_security_and_storage(self): + overrides = { + "cifmw_test_operator_privileged": True, + "cifmw_test_operator_storage_class": "crc-csi-hostpath-provisioner", + "cifmw_test_operator_selinux_level": "s0:c1,c2", + } + fields = { + key: self.defaults["cifmw_test_operator_ansibletest_config"]["spec"][key] + for key in ("privileged", "storageClass", "SELinuxLevel") + } + cr = {"spec": self.render(fields, overrides)} + self.assertTrue(cr["spec"]["privileged"]) + self.assertEqual(cr["spec"]["storageClass"], "crc-csi-hostpath-provisioner") + self.assertEqual(cr["spec"]["SELinuxLevel"], "s0:c1,c2") + other = {"spec": self.render(fields)} + self.assertFalse(other["spec"]["privileged"]) + self.assertEqual(other["spec"]["storageClass"], "local-storage") + + def test_reader_security_context_is_stage_specific(self): + context = {"runAsUser": 227, "seLinuxOptions": {"level": "s0:c1,c2"}} + pod = self.render( + self.defaults["cifmw_test_operator_log_pod_definition"], + {"cifmw_test_operator_log_pod_security_context": context}, + omit="OMITTED", + ) + self.assertEqual(pod["spec"]["securityContext"], context) + other = self.render( + self.defaults["cifmw_test_operator_log_pod_definition"], omit="OMITTED" + ) + self.assertEqual(other["spec"]["securityContext"], "OMITTED") + + def test_timeout_depends_on_phase_including_last_retry(self): + tasks = yaml.safe_load((ROLE / "tasks/run-test-operator-job.yml").read_text()) + block = next(task["block"] for task in tasks if "block" in task) + check = next( + task for task in block if task["name"].startswith("Check whether timed out") + ) + expression = check["ansible.builtin.set_fact"]["testpod_timed_out"] + for phase, expected in [ + ("Succeeded", False), + ("Failed", False), + ("Pending", True), + ]: + with self.subTest(phase=phase): + result = self.render( + expression, + testpod={ + "resources": [{"status": {"phase": phase}}], + "attempts": 3, + }, + cifmw_test_operator_timeout=30, + ) + self.assertEqual(result, expected) + self.assertTrue( + self.render(expression, testpod={"resources": [], "attempts": 3}) + ) + + +if __name__ == "__main__": + unittest.main() From 03241d81f4544a0e36939bb3716abde48a89373e Mon Sep 17 00:00:00 2001 From: malingatembo Date: Sun, 11 Oct 2026 14:43:35 +0200 Subject: [PATCH 08/13] [ci] Enable retrieval tool calls in the Lightspeed mock Request real document retrieval before the fixed answer so the OKP tests can validate retrieved documentation. Support JSON and streaming tool calls, and return the answer after the tool result to avoid repeated retrieval. Related-Issue: OSPRH-33344 Signed-off-by: malingatembo --- .../install-openstack-lightspeed.yml | 49 +++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/hooks/playbooks/install-openstack-lightspeed.yml b/hooks/playbooks/install-openstack-lightspeed.yml index f9d27c260..0edf6b070 100644 --- a/hooks/playbooks/install-openstack-lightspeed.yml +++ b/hooks/playbooks/install-openstack-lightspeed.yml @@ -141,6 +141,7 @@ data: app.py: | import json + import uuid from fastapi import FastAPI, Request from fastapi.responses import JSONResponse, StreamingResponse @@ -148,6 +149,39 @@ app = FastAPI() mock_model = {{ cifmw_openstack_lightspeed_model_name | to_json }} + def retrieval_call(body): + # Ask OGX to retrieve real documents once per user turn. + messages = body.get("messages") or [] + user_index = next(( + i for i in range(len(messages) - 1, -1, -1) + if messages[i].get("role") == "user" + ), None) + if user_index is None or any( + message.get("role") == "tool" for message in messages[user_index + 1:] + ): + return None + tool_choice = body.get("tool_choice", "auto") + if tool_choice == "none": + return None + query = messages[user_index].get("content") + if isinstance(query, list): + query = "\n".join(part["text"] for part in query if part.get("type") == "text") + if not query: + return None + for tool in body.get("tools") or []: + function = tool.get("function", {}) + name = function.get("name") + if tool.get("type") != "function" or name not in ("file_search", "knowledge_search"): + continue + if isinstance(tool_choice, dict) and tool_choice.get("function", {}).get("name") != name: + continue + return { + "id": "call_" + uuid.uuid4().hex, + "type": "function", + "function": {"name": name, "arguments": json.dumps({"query": query})}, + } + return None + @app.get("/v1/models") async def models_get(): return { @@ -194,6 +228,13 @@ } } + choice = response["choices"][0] + tool_call = retrieval_call(body) + if tool_call is not None: + choice["message"]["content"] = None + choice["message"]["tool_calls"] = [tool_call] + choice["finish_reason"] = "tool_calls" + # The provider adapter requests streaming even for /v1/query. if body.get("stream"): def stream_chunks(): @@ -203,11 +244,13 @@ "created": response["created"], "model": model, } - content = response["choices"][0]["message"]["content"] + delta = {"content": choice["message"]["content"]} + if tool_call is not None: + delta = {"tool_calls": [{"index": 0, **tool_call}]} for delta, finish_reason in [ ({"role": "assistant"}, None), - ({"content": content}, None), - ({}, "stop"), + (delta, None), + ({}, choice["finish_reason"]), ]: chunk["choices"] = [{ "index": 0, From a18443586da1a45c3edaddbb0a0fa53db75bab35 Mon Sep 17 00:00:00 2001 From: malingatembo Date: Sun, 11 Oct 2026 23:18:55 +0200 Subject: [PATCH 09/13] [ci] Allow the Lightspeed OGX image to be selected Add an optional OGX image override to the deployment hook while retaining operator defaults when unset. Document the parameter and recognize OGX in documentation spelling checks. Related-Issue: #OSPRH-33344 Signed-off-by: malingatembo --- docs/dictionary/en-custom.txt | 1 + docs/source/usage/lightspeed-testing.md | 4 ++++ hooks/playbooks/install-openstack-lightspeed.yml | 3 +++ 3 files changed, 8 insertions(+) diff --git a/docs/dictionary/en-custom.txt b/docs/dictionary/en-custom.txt index 1f66c6c90..fa6b76685 100644 --- a/docs/dictionary/en-custom.txt +++ b/docs/dictionary/en-custom.txt @@ -473,6 +473,7 @@ ocp ocpbm ocppr odkvmf +ogx oidc oidcaccesstoken oidcauthcode diff --git a/docs/source/usage/lightspeed-testing.md b/docs/source/usage/lightspeed-testing.md index 1e7af8eea..dba6c61d0 100644 --- a/docs/source/usage/lightspeed-testing.md +++ b/docs/source/usage/lightspeed-testing.md @@ -36,6 +36,10 @@ ordinary or streamed completions. Readiness remains bounded by 120 retries at ten-second intervals, configurable with `cifmw_openstack_lightspeed_ready_retries`. +Set `cifmw_openstack_lightspeed_ogx_image` to override only the OGX container +image through the custom resource. When unset, the operator selects its default +image. The API container keeps its operator default in either case. + ## Source and authentication Require `openstack-k8s-operators/lightspeed-tests` in the job. While changes diff --git a/hooks/playbooks/install-openstack-lightspeed.yml b/hooks/playbooks/install-openstack-lightspeed.yml index 0edf6b070..e52037b3e 100644 --- a/hooks/playbooks/install-openstack-lightspeed.yml +++ b/hooks/playbooks/install-openstack-lightspeed.yml @@ -423,6 +423,9 @@ name: "{{ cifmw_openstack_lightspeed_cr_name | default('openstacklightspeed-sample') }}" namespace: "{{ openstack_lightspeed_namespace }}" spec: + ogx: >- + {{ {'containerImage': cifmw_openstack_lightspeed_ogx_image} + if cifmw_openstack_lightspeed_ogx_image is defined else omit }} defaultModel: default-model models: - name: default-model From af306821d5e370e8756f7dde674f59ae3b4ef008 Mon Sep 17 00:00:00 2001 From: malingatembo Date: Mon, 12 Oct 2026 00:08:06 +0200 Subject: [PATCH 10/13] [ci] Restore upstream Lightspeed test prerequisites Restore the Lightspeed access role and binding plus the test-operator ConfigMap and two Secrets accidentally dropped during conflict resolution. Preserve upstream callers and retain the mock retrieval, readiness handling and optional OGX image override. Related-Issue: #OSPRH-33344 Signed-off-by: malingatembo --- .../install-openstack-lightspeed.yml | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/hooks/playbooks/install-openstack-lightspeed.yml b/hooks/playbooks/install-openstack-lightspeed.yml index e52037b3e..392da10ae 100644 --- a/hooks/playbooks/install-openstack-lightspeed.yml +++ b/hooks/playbooks/install-openstack-lightspeed.yml @@ -78,6 +78,43 @@ metadata: name: "{{ openstack_lightspeed_namespace }}" + - name: Create ClusterRole for Lightspeed tests + kubernetes.core.k8s: + state: present + definition: + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: "lightspeed-test-role" + rules: + - nonResourceURLs: ["/ls-access"] + verbs: ["get"] + register: _result + retries: 5 + delay: 10 + until: _result is not failed + + - name: Create ClusterRoleBinding for Lightspeed tests + kubernetes.core.k8s: + state: present + definition: + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: "lightspeed-test-role-binding" + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: "lightspeed-test-role" + subjects: + - kind: ServiceAccount + name: "default" + namespace: "{{ openstack_lightspeed_namespace }}" + register: _result + retries: 5 + delay: 10 + until: _result is not failed + - name: Create OperatorGroup for OpenStack Lightspeed kubernetes.core.k8s: state: present @@ -410,6 +447,73 @@ failed_when: cert_check.resources | length == 0 when: cifmw_openstack_lightspeed_ca_cert_url is defined or cifmw_openstack_lightspeed_ca_cert is defined + - name: Create test-operator dummy config ConfigMap + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: ConfigMap + metadata: + name: "test-operator-dummy-config" + namespace: "{{ openstack_lightspeed_namespace }}" + labels: + app: "lightspeed-tests" + persistent: "true" + managed-by: "test-operator" + data: + clouds.yaml: | + # Dummy file - not used by lightspeed tests + # Required by test-operator but lightspeed tests don't need OpenStack + register: _result + retries: 5 + delay: 10 + until: _result is not failed + + - name: Create test-operator dummy secret + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Secret + metadata: + name: "test-operator-dummy-secret" + namespace: "{{ openstack_lightspeed_namespace }}" + labels: + app: "lightspeed-tests" + persistent: "true" + managed-by: "test-operator" + type: Opaque + stringData: + secure.yaml: | + # Dummy file - not used by lightspeed tests + # Required by test-operator but lightspeed tests don't need OpenStack + register: _result + retries: 5 + delay: 10 + until: _result is not failed + + - name: Create dataplane-ansible-ssh-private-key-secret + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Secret + metadata: + name: "dataplane-ansible-ssh-private-key-secret" + namespace: "{{ openstack_lightspeed_namespace }}" + labels: + app: "lightspeed-tests" + persistent: "true" + managed-by: "test-operator" + type: Opaque + stringData: + ssh-privatekey: | + # Dummy SSH key - not used by lightspeed tests + register: _result + retries: 5 + delay: 10 + until: _result is not failed + # STEP 4: Create OpenStackLightspeed CR to deploy the service # Note: Uses configurable parameters for LLM integration From a8dabab2b3b79b781b31818fd14292797eb9ae04 Mon Sep 17 00:00:00 2001 From: malingatembo Date: Tue, 20 Oct 2026 10:58:00 +0200 Subject: [PATCH 11/13] [test_operator] Simplify Lightspeed CI integration Remove custom source packaging and bootstrap execution in favour of direct AnsibleTest configuration in the consuming job. Restore the shared role, remove the associated PR-added tests, and keep test-account permissions in the Lightspeed installer. Related-Issue: #OSPRH-33344 Signed-off-by: malingatembo --- docs/dictionary/en-custom.txt | 4 +- docs/source/usage/lightspeed-testing.md | 248 +++++++++--------- hooks/playbooks/files/lightspeed-source.py | 77 ------ hooks/playbooks/files/lightspeed-test-pod.yml | 89 ------- .../install-openstack-lightspeed.yml | 26 ++ .../prepare-openstack-lightspeed-tests.yml | 161 ------------ roles/test_operator/README.md | 13 - roles/test_operator/defaults/main.yml | 8 +- .../tasks/run-test-operator-job.yml | 2 +- tests/unit/hooks/test_lightspeed_source.py | 110 -------- tests/unit/roles/test_test_operator_stage.py | 104 -------- 11 files changed, 159 insertions(+), 683 deletions(-) delete mode 100644 hooks/playbooks/files/lightspeed-source.py delete mode 100644 hooks/playbooks/files/lightspeed-test-pod.yml delete mode 100644 hooks/playbooks/prepare-openstack-lightspeed-tests.yml delete mode 100644 tests/unit/hooks/test_lightspeed_source.py delete mode 100644 tests/unit/roles/test_test_operator_stage.py diff --git a/docs/dictionary/en-custom.txt b/docs/dictionary/en-custom.txt index fa6b76685..562ae9d46 100644 --- a/docs/dictionary/en-custom.txt +++ b/docs/dictionary/en-custom.txt @@ -5,7 +5,6 @@ Amartya AssignedTeam CP CPython -CRC's ClusterServiceVersion FreeIPA IDM @@ -30,8 +29,8 @@ NICs NodeHealthCheck NoValidHost OKP +OpenAI OpenStackLightspeed -PRs PV PyYAML RBAC @@ -41,7 +40,6 @@ ServiceAccount Sinha UTF ZipFile -Zuul's aaabbcc abcdefghij accesstoken diff --git a/docs/source/usage/lightspeed-testing.md b/docs/source/usage/lightspeed-testing.md index dba6c61d0..67919be90 100644 --- a/docs/source/usage/lightspeed-testing.md +++ b/docs/source/usage/lightspeed-testing.md @@ -1,8 +1,17 @@ # OpenStack Lightspeed deployment and functional tests -Deploy Lightspeed with the existing hook, then run its integration suite in an -AnsibleTest Pod through the `test_operator` role. The deployment job retains -its inherited Tempest stage: +Deploy Lightspeed with the existing installer, then run its integration playbook +in an AnsibleTest Pod through the existing `test_operator` role. Keep the test +configuration in the consuming operator job's `zuul.d/jobs.yaml`. + +The direct route requires the runner fixes from +[lightspeed-tests PR #17](https://github.com/openstack-k8s-operators/lightspeed-tests/pull/17) +to be merged before using the upstream default branch. Do not submit or recheck +the operator job against an upstream branch that does not contain those fixes. + +## Job configuration + +The core settings are: ```yaml pre_tests_01_deploy_lightspeed_operator: @@ -12,136 +21,135 @@ cifmw_openstack_lightspeed_mock_enabled: true cifmw_run_tests: true cifmw_test_operator_fail_on_test_failure: true cifmw_test_operator_stages: - - name: tempest - type: tempest - name: lightspeed type: ansibletest - pre_test_stage_hooks: - - name: Prepare Lightspeed tests - source: prepare-openstack-lightspeed-tests.yml - type: playbook - test_vars_file: "{{ cifmw_basedir }}/artifacts/lightspeed-test-stage.yml" +cifmw_test_operator_namespace: openstack +cifmw_test_operator_ansibletest_ansible_git_repo: https://github.com/openstack-k8s-operators/lightspeed-tests +cifmw_test_operator_ansibletest_ansible_git_branch: "" +cifmw_test_operator_ansibletest_ansible_playbook_path: playbooks/run_lightspeed_tests.yaml +cifmw_test_operator_ansibletest_ansible_inventory: | + localhost ansible_connection=local ansible_python_interpreter=python3 +cifmw_test_operator_ansibletest_ansible_var_files: | + test_suite_path: /var/lib/ansible/ansible + lightspeed_url: https://lightspeed-app-server.openstack-lightspeed.svc.cluster.local:8443 + lightspeed_timeout: 120 + lightspeed_token_file: /lightspeed-auth/token + pytest_args: "-v --tb=short --color=no tests/integration" + junit_xml_path: /var/lib/AnsibleTests/external_files/baseline.xml +cifmw_test_operator_ansibletest_extra_mounts: + - name: lightspeed + extraVol: + - propagation: [AnsibleTest] + extraVolType: Undefined + volumes: + - name: lightspeed-client-token + projected: + sources: + - serviceAccountToken: + path: token + expirationSeconds: 3600 + mounts: + - name: lightspeed-client-token + mountPath: /lightspeed-auth + readOnly: true ``` -The stage list above preserves the default Tempest stage of the Lightspeed -job's parent. If another caller defines additional stages, retain those too. -The `cifmw_setup` role runs deployment hooks before entering the test role. -The preparation hook runs immediately before the Lightspeed stage. It creates -source and authorization resources and writes stage variables; the existing -role creates the AnsibleTest, waits for its Pods and collects the results. +Copy the image, resources, security and result-reader settings from the consuming +job as well. It uses `openstack-ansible-tests:current-podified`, global privilege, +SELinux and storage settings for its single Lightspeed stage, and the existing +`cifmw_test_operator_log_pod_definition` override for the reader. No shared-role +extensions or preparation hook are required. KUTTL runs separately. The deployment hook installs the catalog, OLM subscription, credential Secret and OpenStackLightspeed resource. The opt-in mock handles model discovery and ordinary or streamed completions. Readiness remains bounded by 120 retries at ten-second intervals, configurable with -`cifmw_openstack_lightspeed_ready_retries`. - -Set `cifmw_openstack_lightspeed_ogx_image` to override only the OGX container -image through the custom resource. When unset, the operator selects its default -image. The API container keeps its operator default in either case. +`cifmw_openstack_lightspeed_ready_retries`. The operator selects its default +application images when no image override is supplied by the job. ## Source and authentication -Require `openstack-k8s-operators/lightspeed-tests` in the job. While changes -remain unmerged, include both framework and tests PR URLs as `Depends-On:` lines -in the operator PR description. A required project alone does not select a PR. - -The preparation hook packages the tracked working files of the Zuul checkout, -including tracked local edits for isolated development. Files outside the index are -excluded; add new test files to the index before a manual run. The immutable -ConfigMap contains a compressed source archive, its revision metadata and a -small bootstrap playbook. A size check leaves room below the ConfigMap limit. -The Pod checks the expected SHA256 before extracting or executing this source. - -The current test image always clones a Git repository on startup. It therefore -still clones the public tests repository, but the absolute playbook path points -to the mounted bootstrap. That bootstrap runs the prepared source in a separate -writable directory. The public clone does not select the pytest revision. The -image also processes a root `requirements.yaml` if one is present in its clone; -that startup behavior and network access remain dependencies of the image. - -The test-operator disables automatic token mounting and currently uses the `default` -ServiceAccount in the test namespace. The stage explicitly projects that account's token -at `/lightspeed-auth/token`; Kubernetes rotates it. A `ClusterRoleBinding` grants -that identity GET access to `/ls-access`. This also grants that permission to -other Pods using the same account in the test namespace. The client token is -separate from the mock provider credential; neither its value nor its contents -are placed in stage variables, source archives or reports. - -Tests run in the existing test namespace, normally `openstack`, and call the -Lightspeed Service in `openstack-lightspeed` over the cluster network. The hook -requires the AnsibleTest CRD and the existing OpenStack ConfigMap, configuration -Secret and compute SSH Secret. It does not install test-operator or create -placeholder OpenStack resources. Those are supplied by the parent deployment. +The test image clones the configured Git repository into +`/var/lib/ansible/ansible` and executes the relative playbook path above. An empty +branch setting uses the cloned repository's default branch. A nonempty setting +selects a published remote branch; arbitrary commit IDs and PR references are +not supported by the inspected image startup script. + +This clone does not use the prepared Zuul checkout. `Depends-On` or adding the +tests repository to `required-projects` does not make its unmerged changes +available to the Pod's independent clone. Merge the required tests changes +first, and inspect the actual Git revision recorded by the runner. There is no +source archive, ConfigMap transport, generated stage file or bootstrap. + +The test-operator disables automatic token mounting and currently uses the +`default` ServiceAccount in the test namespace. The job explicitly projects +that account's token at `/lightspeed-auth/token`; Kubernetes rotates it. The +installer creates the RBAC resources: the `lightspeed-tests-access` `ClusterRole` +and its namespace-specific binding grant that identity GET access to +`/ls-access`. This also grants the permission to other Pods using the same +account in that namespace. Privileged execution does not supply a token. +The client token is separate from the mock provider credential; pass its path, +not its contents, to the runner. + +Tests normally run in `openstack` and call the Lightspeed Service in +`openstack-lightspeed`. `cifmw_test_operator_namespace` selects the test namespace; +the installer also accepts `cifmw_lightspeed_tests_namespace` for its access +binding, which defaults to that test namespace and must match it. +`cifmw_openstack_lightspeed_namespace` selects the application namespace; update +the runner's `lightspeed_url` accordingly. The consuming job templates this URL. +The parent deployment supplies test-operator, its AnsibleTest API and the +existing OpenStack ConfigMap, configuration Secret and compute SSH Secret used +by the stage. Existing AnsibleTest parameters select those resource names. ## Results and failures -The prepared runner installs Python 3.12 and locked dependencies, then selects +The runner installs Python 3.12 and locked dependencies, then runs `tests/integration`: metrics, OKP grounding, authenticated queries and missing -authentication. It writes `baseline.xml` and `baseline.xml.log` on the results -PVC. The bootstrap also retains `runner.log`, `runner-inputs.json` (paths only) -and `source-revision.json`. It requires the runner to succeed and the JUnit -report to contain executed tests with no failures or errors. Empty, missing, -malformed and entirely skipped reports fail the Pod. - -The role checks actual Pod phases, collects completed Pods' PVC contents even -when tests failed, and fails the job when a Pod failed or did not complete. -Completion on the last polling attempt is still collected. The Lightspeed reader -Pod uses user and group ID 227 and the same SELinux level as the test Pod so it can read -the PVC. These security settings apply only to the Lightspeed stage. The role's -usual timeout is 3600 seconds; this is a controller wait, not a Pod termination -deadline. Investigate unfinished Pods if that wait expires. - -The existing collector publishes under -`controller/ci-framework-data/tests/test_operator/`. Inspect the actual nested -paths for `baseline.xml`, its `.log` companion, `runner.log` and -`source-revision.json`. Compare the source revision with the Zuul workspace; -it can be a speculative merge commit. The generated AnsibleTest and reader -manifests are under `artifacts/test-operator-crs`, and preparation metadata is -under `artifacts/lightspeed-tests`. The preparation hook log is -`logs/pre_test_hooks_prepare_lightspeed_tests.log`. - -## Isolated role validation - -Reuse an already deployed, Ready Lightspeed instance and installed test-operator. -This avoids another complete OpenStack deployment. Supply a local prepared tests -checkout, existing OpenStack resource names and the cluster's storage class in -a variables file, for example: - -```yaml -cifmw_basedir: /home/cloud-user/ci-framework-data -cifmw_openshift_kubeconfig: /home/cloud-user/.crc/machines/crc/kubeconfig -cifmw_path: "{{ ansible_env.PATH }}" -cifmw_lightspeed_tests_source: /home/cloud-user/src/lightspeed-tests -cifmw_test_operator_namespace: openstack -cifmw_test_operator_storage_class: crc-csi-hostpath-provisioner -cifmw_test_operator_stages: - - name: lightspeed - type: ansibletest - test_vars_file: "{{ cifmw_basedir }}/artifacts/lightspeed-test-stage.yml" -``` - -First run the preparation hook with that file, then a local play containing -`roles: [test_operator]` with the same variables. These commands create cluster -resources; use them only when cluster execution is intended. The preparation -hook can be rerun safely for source packaging, but a completed AnsibleTest is -not automatically rerun by reapplying an identical resource. Preserve its -reports before removing that run's CR and owned resources for another attempt. - -`cifmw_lightspeed_tests_timeout` controls API request timeouts (120 seconds). -`cifmw_openstack_lightspeed_namespace` selects the application namespace; -`cifmw_lightspeed_tests_namespace` selects the test namespace, defaulting to -`cifmw_test_operator_namespace`. The existing AnsibleTest parameters select -OpenStack ConfigMap and Secret names. The stage uses the existing -`openstack-ansible-tests:current-podified` image and inherits the role's storage -class. It enables the same privileged setting as the standalone Lightspeed -example; privilege does not supply a token. - -Local syntax, source, manifest and simulated Pod checks do not establish -cluster execution. Accept the migration after a real Pod run publishes passing -integration cases and a deliberate failing run retains its reports and fails -the deployment job. A combined Zuul run must also verify image selection, -coordinated revisions and publication. Inspect the non-voting deployment job's -own result; a green buildset can conceal its failure. KUTTL is separate. Mock -results do not establish real-provider credentials, TLS trust or model quality; -the current test client disables TLS verification in this disposable setup. +authentication. It saves `baseline.xml`, `baseline.xml.log` and +`baseline.xml.runner.log` on the results PVC. It requires pytest exit code zero +and an executed JUnit case with no failure or error cases. Empty, missing, +malformed and entirely skipped reports fail. + +The runner log includes its source path, Git revision and tracked changes when +Git metadata is available, input paths and setup/test failure details. It is a +diagnostic summary, not a full Ansible console transcript. Old outputs for the +report path are cleared before setup. Startup, cloning, fact gathering or +inaccessible artifact storage can fail before this log is written; inspect +Pod container logs in those cases. + +The existing role waits for Pods, collects results when its wait has not timed +out, and fails the job when a Pod failed or did not complete. Its usual timeout +is 3600 seconds; this is a controller wait, not a Pod termination deadline. +The reader uses user and group ID 227 and the test Pod's SELinux level to read +the PVC. Its job override retains the reader image, mounts and scheduling settings; +review it if the upstream reader template changes. + +Find reports under `controller/ci-framework-data/tests/test_operator/`, checking +the actual nested paths. Read `baseline.xml` for executed cases and failures, +its `.log` for pytest output and `.runner.log` for revision and failure context. +Generated AnsibleTest and reader manifests are under +`artifacts/test-operator-crs`. For application traffic and Pod state, inspect +must-gather, then `namespaces/openstack-lightspeed/pods/` and each Pod's logs. +The direct route does not produce the former bootstrap's `source-revision.json`, +`runner-inputs.json` or outer `runner.log`. + +## Validation + +For an isolated run, reuse a Ready Lightspeed deployment and installed +test-operator. Ensure the installer has granted the test account access. Copy +the consuming job's test-operator settings into a local variables file, add the +cluster's kubeconfig and storage class, and run a local play containing +`roles: [test_operator]`. No preparation playbook is needed. This creates cluster +resources; preserve reports before removing a completed run's CR and owned +resources to repeat it. Reapplying an identical completed CR does not rerun it. + +Local rendering, syntax and runner fixtures do not prove cluster execution. +After the required tests fixes merge and the coordinated changes are submitted, +run combined Zuul validation. Verify default images, actual source revision, +executed application cases and published artifacts, plus deliberate failure +propagation. Inspect the non-voting deployment job's own result: a green +buildset can conceal its failure. Mock results establish the tested integration +path; real-provider credentials and model quality require separate validation. +Real OpenAI access using Jan's credentials and a separate Lightspeed results +folder are follow-up work. The current test client disables TLS verification +in this disposable setup. diff --git a/hooks/playbooks/files/lightspeed-source.py b/hooks/playbooks/files/lightspeed-source.py deleted file mode 100644 index 15bc0a7bc..000000000 --- a/hooks/playbooks/files/lightspeed-source.py +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env python3 -"""Package tracked working files from the Zuul checkout for an AnsibleTest Pod.""" - -import base64 -import gzip -import hashlib -import io -import json -from pathlib import Path -import subprocess -import sys -import tarfile - - -def package(source, bootstrap, destination): - source = Path(source).resolve() - destination = Path(destination) - - def git(*args): - return subprocess.check_output(["git", "-C", str(source), *args]) - - revision = git("rev-parse", "HEAD").decode().strip() - files = sorted(set(git("ls-files", "-z").decode().rstrip("\0").split("\0"))) - for required in ( - "playbooks/run_lightspeed_tests.yaml", - "pyproject.toml", - "uv.lock", - ): - if required not in files or not (source / required).is_file(): - raise ValueError(f"Missing tracked test input: {required}") - stream = io.BytesIO() - with gzip.GzipFile(fileobj=stream, mode="wb", mtime=0) as compressed: - with tarfile.open(fileobj=compressed, mode="w") as archive: - for name in files: - path = source / name - if not path.is_file() or path.is_symlink(): - raise ValueError(f"Expected a regular tracked file: {name}") - info = archive.gettarinfo(str(path), arcname=name) - info.uid = info.gid = info.mtime = 0 - info.uname = info.gname = "" - with path.open("rb") as content: - archive.addfile(info, content) - data = stream.getvalue() - bootstrap_text = Path(bootstrap).read_text() - # Leave room for metadata and the bootstrap within Kubernetes' 1 MiB limit. - if len(data) + len(bootstrap_text.encode()) > 900 * 1024: - raise ValueError("Lightspeed source exceeds the ConfigMap size budget") - checksum = hashlib.sha256(data).hexdigest() - metadata = { - "revision": revision, - "tracked_changes": git( - "status", "--porcelain", "--untracked-files=no" - ).decode(), - "sha256": checksum, - } - config = { - "apiVersion": "v1", - "kind": "ConfigMap", - "immutable": True, - "data": { - "run.yml": bootstrap_text, - "source-revision.json": json.dumps(metadata, indent=2) + "\n", - }, - "binaryData": {"source.tar.gz": base64.b64encode(data).decode()}, - } - identity = hashlib.sha256(json.dumps(config, sort_keys=True).encode()).hexdigest() - config["metadata"] = {"name": "lightspeed-tests-source-" + identity[:16]} - destination.mkdir(parents=True, exist_ok=True) - (destination / "source.tar.gz").write_bytes(data) - (destination / "source-revision.json").write_text( - json.dumps(metadata, indent=2) + "\n" - ) - (destination / "configmap.json").write_text(json.dumps(config, indent=2) + "\n") - - -if __name__ == "__main__": - package(*sys.argv[1:]) diff --git a/hooks/playbooks/files/lightspeed-test-pod.yml b/hooks/playbooks/files/lightspeed-test-pod.yml deleted file mode 100644 index 0d14f8eea..000000000 --- a/hooks/playbooks/files/lightspeed-test-pod.yml +++ /dev/null @@ -1,89 +0,0 @@ ---- -- name: Run the prepared Lightspeed source inside the test Pod - hosts: localhost - connection: local - gather_facts: true - vars: - cifmw_lightspeed_tests_mount: /lightspeed-source - cifmw_lightspeed_tests_checkout: /var/lib/ansible/lightspeed-tests - cifmw_lightspeed_tests_results: /var/lib/AnsibleTests/external_files - tasks: - - name: Check the mounted source archive - ansible.builtin.stat: - path: "{{ cifmw_lightspeed_tests_mount }}/source.tar.gz" - follow: true - checksum_algorithm: sha256 - register: cifmw_lightspeed_tests_archive - failed_when: cifmw_lightspeed_tests_archive.stat.checksum | default('') != cifmw_lightspeed_tests_sha256 - - - name: Create a writable test checkout - ansible.builtin.file: - path: "{{ cifmw_lightspeed_tests_checkout }}" - state: directory - mode: '0755' - - - name: Extract the prepared test source - ansible.builtin.unarchive: - src: "{{ cifmw_lightspeed_tests_mount }}/source.tar.gz" - dest: "{{ cifmw_lightspeed_tests_checkout }}" - remote_src: true - - - name: Preserve the source identity in the results PVC - ansible.builtin.copy: - src: "{{ cifmw_lightspeed_tests_mount }}/source-revision.json" - dest: "{{ cifmw_lightspeed_tests_results }}/source-revision.json" - remote_src: true - mode: '0644' - - - name: Write runner inputs containing only the token path - ansible.builtin.copy: - content: "{{ cifmw_lightspeed_tests_inputs | to_nice_json }}" - dest: "{{ cifmw_lightspeed_tests_results }}/runner-inputs.json" - mode: '0644' - - - name: Execute the prepared Lightspeed runner - ansible.builtin.command: - argv: - - ansible-playbook - - "{{ cifmw_lightspeed_tests_checkout }}/playbooks/run_lightspeed_tests.yaml" - - -i - - localhost, - - -e - - "@{{ cifmw_lightspeed_tests_results }}/runner-inputs.json" - register: cifmw_lightspeed_tests_run - changed_when: true - failed_when: false - - - name: Preserve runner output even when setup or tests fail - ansible.builtin.copy: - dest: "{{ cifmw_lightspeed_tests_results }}/runner.log" - content: | - {{ cifmw_lightspeed_tests_run.stdout | default('') }} - {{ cifmw_lightspeed_tests_run.stderr | default('') }} - {{ cifmw_lightspeed_tests_run.msg | default('') }} - Exit code: {{ cifmw_lightspeed_tests_run.rc | default(-1) }} - mode: '0644' - - - name: Require an executed passing integration report - ansible.builtin.command: - argv: - - "{{ ansible_playbook_python }}" - - -c - - | - import json, sys, xml.etree.ElementTree as ET - cases = list(ET.parse(sys.argv[1]).iter('testcase')) - skipped = sum(case.find('skipped') is not None for case in cases) - failed = sum(case.find('failure') is not None or case.find('error') is not None for case in cases) - print(json.dumps({'tests': len(cases), 'skipped': skipped, 'failed': failed})) - sys.exit(0 if len(cases) > skipped and failed == 0 else 1) - - "{{ cifmw_lightspeed_tests_inputs.junit_xml_path }}" - register: cifmw_lightspeed_tests_report - changed_when: false - failed_when: false - - - name: Propagate runner and report failures to the test Pod - ansible.builtin.assert: - that: - - cifmw_lightspeed_tests_run.rc | default(-1) == 0 - - cifmw_lightspeed_tests_report.rc == 0 - fail_msg: "Lightspeed tests failed or produced no executed passing tests. Inspect runner.log and baseline.xml on the results PVC." diff --git a/hooks/playbooks/install-openstack-lightspeed.yml b/hooks/playbooks/install-openstack-lightspeed.yml index 392da10ae..52ffcca96 100644 --- a/hooks/playbooks/install-openstack-lightspeed.yml +++ b/hooks/playbooks/install-openstack-lightspeed.yml @@ -18,6 +18,7 @@ hosts: "{{ cifmw_target_hook_host | default('localhost') }}" connection: local vars: + cifmw_lightspeed_tests_namespace: "{{ cifmw_test_operator_namespace | default('openstack') }}" # OpenStack Lightspeed configuration # Note: Using openstack-lightspeed namespace per operator's suggested namespace openstack_lightspeed_namespace: "{{ cifmw_openstack_lightspeed_namespace | default('openstack-lightspeed') }}" @@ -115,6 +116,31 @@ delay: 10 until: _result is not failed + - name: Authorize the test Pod's explicitly projected client token + kubernetes.core.k8s: + state: present + definition: + - apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: lightspeed-tests-access + rules: + - nonResourceURLs: [/ls-access] + verbs: [get] + - apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: "lightspeed-tests-access-{{ cifmw_lightspeed_tests_namespace }}" + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: lightspeed-tests-access + subjects: + # The current test-operator uses the namespace's default account. + - kind: ServiceAccount + name: default + namespace: "{{ cifmw_lightspeed_tests_namespace }}" + - name: Create OperatorGroup for OpenStack Lightspeed kubernetes.core.k8s: state: present diff --git a/hooks/playbooks/prepare-openstack-lightspeed-tests.yml b/hooks/playbooks/prepare-openstack-lightspeed-tests.yml deleted file mode 100644 index ce2eebc58..000000000 --- a/hooks/playbooks/prepare-openstack-lightspeed-tests.yml +++ /dev/null @@ -1,161 +0,0 @@ ---- -- name: Prepare OpenStack Lightspeed for the test operator stage - hosts: "{{ cifmw_target_hook_host | default('localhost') }}" - connection: local - vars: - cifmw_lightspeed_tests_namespace: "{{ cifmw_test_operator_namespace | default('openstack') }}" - cifmw_lightspeed_tests_source: "{{ ansible_env.HOME }}/src/github.com/openstack-k8s-operators/lightspeed-tests" - cifmw_lightspeed_tests_artifacts: "{{ cifmw_basedir }}/artifacts/lightspeed-tests" - cifmw_lightspeed_tests_timeout: 120 - cifmw_lightspeed_tests_kubeconfig: "{{ cifmw_openshift_kubeconfig | default(ansible_env.HOME ~ '/.crc/machines/crc/kubeconfig') }}" - cifmw_lightspeed_tests_selinux_level: "{{ cifmw_test_operator_selinux_level | default('s0:c478,c978') }}" - environment: - KUBECONFIG: "{{ cifmw_lightspeed_tests_kubeconfig }}" - tasks: - - name: Require the installed AnsibleTest API - kubernetes.core.k8s_info: - api_version: apiextensions.k8s.io/v1 - kind: CustomResourceDefinition - name: ansibletests.test.openstack.org - register: cifmw_lightspeed_tests_api - failed_when: cifmw_lightspeed_tests_api.resources | length != 1 - - - name: Check the existing OpenStack configuration and SSH resources - kubernetes.core.k8s_info: - kind: "{{ item.kind }}" - name: "{{ item.name }}" - namespace: "{{ cifmw_lightspeed_tests_namespace }}" - register: cifmw_lightspeed_tests_prerequisite - failed_when: cifmw_lightspeed_tests_prerequisite.resources | length != 1 - # k8s_info returns Secret data; never include it in the hook log. - no_log: true - loop: - - kind: ConfigMap - name: "{{ cifmw_test_operator_ansibletest_openstack_config_map | default('openstack-config') }}" - - kind: Secret - name: "{{ cifmw_test_operator_ansibletest_openstack_config_secret | default('openstack-config-secret') }}" - - kind: Secret - name: "{{ cifmw_test_operator_ansibletest_compute_ssh_key_secret_name | default('dataplane-ansible-ssh-private-key-secret') }}" - - - name: Create the source artifact directory - ansible.builtin.file: - path: "{{ cifmw_lightspeed_tests_artifacts }}" - state: directory - mode: '0755' - - - name: Package the tracked working files from the prepared checkout - ansible.builtin.command: - argv: - - "{{ ansible_playbook_python }}" - - "{{ playbook_dir }}/files/lightspeed-source.py" - - "{{ cifmw_lightspeed_tests_source }}" - - "{{ playbook_dir }}/files/lightspeed-test-pod.yml" - - "{{ cifmw_lightspeed_tests_artifacts }}" - changed_when: true - - - name: Read the packaged source definition and identity - ansible.builtin.set_fact: - cifmw_lightspeed_tests_configmap: "{{ lookup('file', cifmw_lightspeed_tests_artifacts ~ '/configmap.json') | from_json }}" - cifmw_lightspeed_tests_identity: "{{ lookup('file', cifmw_lightspeed_tests_artifacts ~ '/source-revision.json') | from_json }}" - no_log: true - - - name: Mount the immutable prepared source for the test Pod - kubernetes.core.k8s: - state: present - namespace: "{{ cifmw_lightspeed_tests_namespace }}" - definition: "{{ cifmw_lightspeed_tests_configmap }}" - no_log: true - - - name: Authorize the test Pod's explicitly projected client token - kubernetes.core.k8s: - state: present - definition: - - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRole - metadata: - name: lightspeed-tests-access - rules: - - nonResourceURLs: [/ls-access] - verbs: [get] - - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRoleBinding - metadata: - name: "lightspeed-tests-access-{{ cifmw_lightspeed_tests_namespace }}" - roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: lightspeed-tests-access - subjects: - # The current test-operator uses the namespace's default account. - - kind: ServiceAccount - name: default - namespace: "{{ cifmw_lightspeed_tests_namespace }}" - - - name: Write the Lightspeed stage variables for the existing test operator role - vars: - cifmw_lightspeed_tests_stage_vars: - cifmw_test_operator_namespace: "{{ cifmw_lightspeed_tests_namespace }}" - cifmw_test_operator_privileged: true - cifmw_test_operator_selinux_level: "{{ cifmw_lightspeed_tests_selinux_level }}" - cifmw_test_operator_ansibletest_name: ansibletest - cifmw_test_operator_ansibletest_image: quay.io/podified-antelope-centos9/openstack-ansible-tests - cifmw_test_operator_ansibletest_image_tag: current-podified - # The image clones this repository as part of its entrypoint. The mounted - # playbook below executes only the checksum-verified prepared source. - cifmw_test_operator_ansibletest_ansible_git_repo: https://github.com/openstack-k8s-operators/lightspeed-tests - cifmw_test_operator_ansibletest_ansible_git_branch: "" - cifmw_test_operator_ansibletest_ansible_playbook_path: /lightspeed-source/run.yml - cifmw_test_operator_ansibletest_ansible_inventory: | - localhost ansible_connection=local ansible_python_interpreter=python3 - cifmw_test_operator_ansibletest_debug: false - cifmw_test_operator_ansibletest_resources: - requests: - cpu: 1000m - memory: 1Gi - limits: - cpu: 2000m - memory: 2Gi - cifmw_test_operator_ansibletest_ansible_var_files: "{{ cifmw_lightspeed_tests_pod_vars | to_nice_yaml }}" - cifmw_test_operator_ansibletest_extra_mounts: - - name: lightspeed - extraVol: - - propagation: [AnsibleTest] - extraVolType: Undefined - volumes: - - name: lightspeed-source - configMap: - name: "{{ cifmw_lightspeed_tests_configmap.metadata.name }}" - - name: lightspeed-client-token - projected: - sources: - - serviceAccountToken: - path: token - expirationSeconds: 3600 - mounts: - - name: lightspeed-source - mountPath: /lightspeed-source - readOnly: true - - name: lightspeed-client-token - mountPath: /lightspeed-auth - readOnly: true - # Match the test Pod's UID and SELinux level to read its results PVC. - # Keep these changes local to this stage, including the reader Pod. - cifmw_test_operator_log_pod_security_context: - runAsUser: 227 - runAsGroup: 227 - fsGroup: 227 - seLinuxOptions: - level: "{{ cifmw_lightspeed_tests_selinux_level }}" - cifmw_lightspeed_tests_pod_vars: - cifmw_lightspeed_tests_sha256: "{{ cifmw_lightspeed_tests_identity.sha256 }}" - cifmw_lightspeed_tests_inputs: - test_suite_path: /var/lib/ansible/lightspeed-tests - lightspeed_url: "https://lightspeed-app-server.{{ cifmw_openstack_lightspeed_namespace | default('openstack-lightspeed') }}.svc.cluster.local:8443" - lightspeed_timeout: "{{ cifmw_lightspeed_tests_timeout | int }}" - lightspeed_token_file: /lightspeed-auth/token - pytest_args: "-v --tb=short --color=no tests/integration" - junit_xml_path: /var/lib/AnsibleTests/external_files/baseline.xml - ansible.builtin.copy: - dest: "{{ cifmw_basedir }}/artifacts/lightspeed-test-stage.yml" - content: "{{ cifmw_lightspeed_tests_stage_vars | to_nice_yaml }}" - mode: '0644' diff --git a/roles/test_operator/README.md b/roles/test_operator/README.md index 9155ee465..cc508f00a 100644 --- a/roles/test_operator/README.md +++ b/roles/test_operator/README.md @@ -23,7 +23,6 @@ Execute tests via the [test-operator](https://openstack-k8s-operators.github.io/ * `cifmw_test_operator_fail_on_test_failure`: (Boolean) Whether the role should fail on any test failure or not. Default value: `true` * `cifmw_test_operator_log_pod_definition`: (Object) The CR definition template for creating the test log pod. Default value: See defaults in [main.yml](https://github.com/openstack-k8s-operators/ci-framework/blob/main/roles/test_operator/defaults/main.yml) * `cifmw_test_operator_logs_image`: (String) Image that should be used to collect logs from the pods spawned by the test-operator. Default value: `quay.io/quay/busybox` -* `cifmw_test_operator_log_pod_security_context`: (Dict) Security context for the results reader Pod, with stage overrides supported. Default value: `{}` * `cifmw_test_operator_namespace`: (String) Namespace inside which all the resources are created. Default value: `openstack` * `cifmw_test_operator_node_selector`: (Dict) `nodeSelector` value that is applied to all pods spawned by the test-operator and to the test-operator-controller-manager and test-operator-logs pods. Default value: `{}` * `cifmw_test_operator_privileged`: (Boolean) Spawn the test pods with `allowPrivilegedEscalation: true` and default linux capabilities. This is required for certain test-operator functionalities to work properly (e.g.: `extraRPMs`, certain set of tobiko tests). Default value: `false` @@ -197,15 +196,3 @@ cifmw_test_operator_stages: type: tobiko test_vars_file: /path/to/tobiko/override/test/file ``` - -## AnsibleTest stage overrides - -The AnsibleTest manifest uses stage-specific `cifmw_test_operator_privileged`, -`cifmw_test_operator_selinux_level` and `cifmw_test_operator_storage_class` -values when provided in `test_vars` or `test_vars_file`. The collector also -uses the stage's `cifmw_test_operator_log_pod_security_context` (default `{}`). This lets a stage -match the reader Pod's security context to the test Pod without changing -other stages. With no stage override the existing global values apply. - -A terminal Pod phase on the final polling attempt is completion, not a timeout; -its logs are collected before the normal success/failure decision. diff --git a/roles/test_operator/defaults/main.yml b/roles/test_operator/defaults/main.yml index 95a357a61..49f0adcd4 100644 --- a/roles/test_operator/defaults/main.yml +++ b/roles/test_operator/defaults/main.yml @@ -43,7 +43,6 @@ cifmw_test_operator_delete_logs_pod: false cifmw_test_operator_privileged: false cifmw_test_operator_selinux_level: "s0:c478,c978" cifmw_test_operator_crs_path: "{{ cifmw_basedir }}/artifacts/test-operator-crs" -cifmw_test_operator_log_pod_security_context: {} cifmw_test_operator_log_pod_definition: apiVersion: v1 kind: Pod @@ -51,7 +50,6 @@ cifmw_test_operator_log_pod_definition: name: "test-operator-logs-pod-{{ run_test_fw }}-{{ test_operator_instance_name }}" namespace: "{{ stage_vars_dict.cifmw_test_operator_namespace }}" spec: - securityContext: "{{ stage_vars_dict.cifmw_test_operator_log_pod_security_context | default({}) or omit }}" containers: - name: test-operator-logs-container image: "{{ cifmw_test_operator_logs_image }}" @@ -263,11 +261,11 @@ cifmw_test_operator_ansibletest_config: name: "{{ stage_vars_dict.cifmw_test_operator_ansibletest_name }}-{{ _stage_vars.name }}" namespace: "{{ stage_vars_dict.cifmw_test_operator_namespace }}" spec: - SELinuxLevel: "{{ stage_vars_dict.cifmw_test_operator_selinux_level }}" + SELinuxLevel: "{{ cifmw_test_operator_selinux_level }}" containerImage: "{{ stage_vars_dict.cifmw_test_operator_ansibletest_image }}:{{ stage_vars_dict.cifmw_test_operator_ansibletest_image_tag }}" extraMounts: "{{ stage_vars_dict.cifmw_test_operator_ansibletest_extra_mounts | default(omit) }}" - storageClass: "{{ stage_vars_dict.cifmw_test_operator_storage_class }}" - privileged: "{{ stage_vars_dict.cifmw_test_operator_privileged }}" + storageClass: "{{ cifmw_test_operator_storage_class }}" + privileged: "{{ cifmw_test_operator_privileged }}" computeSSHKeySecretName: "{{ stage_vars_dict.cifmw_test_operator_ansibletest_compute_ssh_key_secret_name }}" workloadSSHKeySecretName: "{{ stage_vars_dict.cifmw_test_operator_ansibletest_workload_ssh_key_secret_name }}" ansibleGitRepo: "{{ stage_vars_dict.cifmw_test_operator_ansibletest_ansible_git_repo }}" diff --git a/roles/test_operator/tasks/run-test-operator-job.yml b/roles/test_operator/tasks/run-test-operator-job.yml index b15b721b3..bab2d7837 100644 --- a/roles/test_operator/tasks/run-test-operator-job.yml +++ b/roles/test_operator/tasks/run-test-operator-job.yml @@ -72,7 +72,7 @@ - name: Check whether timed out - {{ run_test_fw }} ansible.builtin.set_fact: testpod_timed_out: >- - {{ testpod.resources[0].status.phase | default('') not in ['Succeeded', 'Failed'] }} + {{ testpod.attempts == (cifmw_test_operator_timeout / 10) | round | int }} - name: Collect logs when: diff --git a/tests/unit/hooks/test_lightspeed_source.py b/tests/unit/hooks/test_lightspeed_source.py deleted file mode 100644 index 857247c16..000000000 --- a/tests/unit/hooks/test_lightspeed_source.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Source transport contracts; no network or Kubernetes access required.""" - -import base64 -import hashlib -import importlib.util -import io -import json -import os -from pathlib import Path -import subprocess -import tarfile -import tempfile -import unittest - -SCRIPT = ( - Path(__file__).resolve().parents[3] / "hooks/playbooks/files/lightspeed-source.py" -) -spec = importlib.util.spec_from_file_location("lightspeed_source", SCRIPT) -source_module = importlib.util.module_from_spec(spec) -spec.loader.exec_module(source_module) - - -class TestSourceArchive(unittest.TestCase): - def setUp(self): - self.tmp = tempfile.TemporaryDirectory() - self.addCleanup(self.tmp.cleanup) - self.root = Path(self.tmp.name) - self.repo = self.root / "repo" - self.repo.mkdir() - self.git("init", "-q") - for name in [ - "playbooks/run_lightspeed_tests.yaml", - "pyproject.toml", - "uv.lock", - ]: - path = self.repo / name - path.parent.mkdir(exist_ok=True) - path.write_text("initial\n") - self.git("add", ".") - self.git( - "-c", - "user.name=Fixture", - "-c", - "user.email=fixture@example.invalid", - "-c", - "commit.gpgsign=false", - "commit", - "-qm", - "Fixture baseline", - ) - self.bootstrap = self.root / "run.yml" - self.bootstrap.write_text("---\n[]\n") - self.output = self.root / "output" - - def git(self, *args): - return subprocess.check_output(["git", "-C", str(self.repo), *args]) - - def package(self): - source_module.package(self.repo, self.bootstrap, self.output) - return json.loads((self.output / "configmap.json").read_text()) - - def test_working_edits_and_indexed_additions_are_used(self): - (self.repo / "uv.lock").write_text("tracked local edit\n") - (self.repo / "added.py").write_text("new source\n") - self.git("add", "added.py") - (self.repo / "untracked-token").write_text("never archive this") - config = self.package() - archive_data = base64.b64decode(config["binaryData"]["source.tar.gz"]) - with tarfile.open(fileobj=io.BytesIO(archive_data), mode="r:gz") as archive: - self.assertEqual( - archive.extractfile("uv.lock").read(), b"tracked local edit\n" - ) - self.assertIn("added.py", archive.getnames()) - self.assertNotIn("untracked-token", archive.getnames()) - metadata = json.loads(config["data"]["source-revision.json"]) - self.assertEqual(metadata["sha256"], hashlib.sha256(archive_data).hexdigest()) - self.assertEqual( - metadata["revision"], self.git("rev-parse", "HEAD").decode().strip() - ) - self.assertIn("uv.lock", metadata["tracked_changes"]) - - def test_identical_inputs_produce_identical_configmap(self): - before = self.package() - os.utime(self.repo / "uv.lock", (100, 100)) - self.assertEqual(before, self.package()) - self.bootstrap.write_text("---\n- name: Updated bootstrap\n") - self.assertNotEqual( - before["metadata"]["name"], self.package()["metadata"]["name"] - ) - - def test_missing_required_input_is_rejected(self): - (self.repo / "uv.lock").unlink() - with self.assertRaisesRegex(ValueError, "Missing tracked test input"): - self.package() - - def test_symlink_is_not_followed_into_private_files(self): - (self.repo / "linked").symlink_to(self.bootstrap) - self.git("add", "linked") - with self.assertRaisesRegex(ValueError, "regular tracked file"): - self.package() - - def test_oversize_configmap_is_rejected(self): - (self.repo / "large").write_bytes(os.urandom(1024 * 1024)) - self.git("add", "large") - with self.assertRaisesRegex(ValueError, "size budget"): - self.package() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/roles/test_test_operator_stage.py b/tests/unit/roles/test_test_operator_stage.py deleted file mode 100644 index 202a12917..000000000 --- a/tests/unit/roles/test_test_operator_stage.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Render stage settings without connecting to a Kubernetes cluster.""" - -from copy import deepcopy -from pathlib import Path -import unittest - -from ansible.parsing.dataloader import DataLoader -from ansible.template import Templar -import yaml - -ROLE = Path(__file__).resolve().parents[3] / "roles/test_operator" - - -class TestStageSettings(unittest.TestCase): - def setUp(self): - self.defaults = yaml.safe_load((ROLE / "defaults/main.yml").read_text()) - - def render(self, variable, overrides=None, **extra): - stage = { - key: deepcopy(self.defaults[key]) - for key in ( - "cifmw_test_operator_namespace", - "cifmw_test_operator_privileged", - "cifmw_test_operator_storage_class", - "cifmw_test_operator_selinux_level", - "cifmw_test_operator_log_pod_security_context", - ) - } - stage.update(overrides or {}) - variables = dict(self.defaults) - variables.update( - stage_vars_dict=stage, - _stage_vars={"name": "fixture"}, - cifmw_default_registry="quay.io", - cifmw_default_container_image_namespace="example", - cifmw_default_container_image_tag="current", - run_test_fw="ansibletest", - test_operator_instance_name="ansibletest-fixture", - _test_operator_volume_mounts=[], - _test_operator_volumes=[], - ) - variables.update(extra) - return Templar(loader=DataLoader(), variables=variables).template(variable) - - def test_ansibletest_stage_security_and_storage(self): - overrides = { - "cifmw_test_operator_privileged": True, - "cifmw_test_operator_storage_class": "crc-csi-hostpath-provisioner", - "cifmw_test_operator_selinux_level": "s0:c1,c2", - } - fields = { - key: self.defaults["cifmw_test_operator_ansibletest_config"]["spec"][key] - for key in ("privileged", "storageClass", "SELinuxLevel") - } - cr = {"spec": self.render(fields, overrides)} - self.assertTrue(cr["spec"]["privileged"]) - self.assertEqual(cr["spec"]["storageClass"], "crc-csi-hostpath-provisioner") - self.assertEqual(cr["spec"]["SELinuxLevel"], "s0:c1,c2") - other = {"spec": self.render(fields)} - self.assertFalse(other["spec"]["privileged"]) - self.assertEqual(other["spec"]["storageClass"], "local-storage") - - def test_reader_security_context_is_stage_specific(self): - context = {"runAsUser": 227, "seLinuxOptions": {"level": "s0:c1,c2"}} - pod = self.render( - self.defaults["cifmw_test_operator_log_pod_definition"], - {"cifmw_test_operator_log_pod_security_context": context}, - omit="OMITTED", - ) - self.assertEqual(pod["spec"]["securityContext"], context) - other = self.render( - self.defaults["cifmw_test_operator_log_pod_definition"], omit="OMITTED" - ) - self.assertEqual(other["spec"]["securityContext"], "OMITTED") - - def test_timeout_depends_on_phase_including_last_retry(self): - tasks = yaml.safe_load((ROLE / "tasks/run-test-operator-job.yml").read_text()) - block = next(task["block"] for task in tasks if "block" in task) - check = next( - task for task in block if task["name"].startswith("Check whether timed out") - ) - expression = check["ansible.builtin.set_fact"]["testpod_timed_out"] - for phase, expected in [ - ("Succeeded", False), - ("Failed", False), - ("Pending", True), - ]: - with self.subTest(phase=phase): - result = self.render( - expression, - testpod={ - "resources": [{"status": {"phase": phase}}], - "attempts": 3, - }, - cifmw_test_operator_timeout=30, - ) - self.assertEqual(result, expected) - self.assertTrue( - self.render(expression, testpod={"resources": [], "attempts": 3}) - ) - - -if __name__ == "__main__": - unittest.main() From 3b67dd8282c5d23f7d670c30b3eedd169249882d Mon Sep 17 00:00:00 2001 From: malingatembo Date: Tue, 27 Oct 2026 11:31:46 +0100 Subject: [PATCH 12/13] [ci] Group Lightspeed test preparation in the installer Separate test-operator access and dummy prerequisites from service deployment, use the configured test namespace directly, and remove redundant rescue diagnostics. Preserve task behaviour and the existing must-gather collection. Validation: production ansible-lint, syntax and documentation build; offline task/object parity, namespace and natural failure-propagation checks passed. Related-Issue: #OSPRH-33344 Signed-off-by: malingatembo --- docs/source/usage/lightspeed-testing.md | 7 +- .../install-openstack-lightspeed.yml | 235 ++++++++---------- 2 files changed, 111 insertions(+), 131 deletions(-) diff --git a/docs/source/usage/lightspeed-testing.md b/docs/source/usage/lightspeed-testing.md index 67919be90..35ad1ae66 100644 --- a/docs/source/usage/lightspeed-testing.md +++ b/docs/source/usage/lightspeed-testing.md @@ -92,9 +92,10 @@ The client token is separate from the mock provider credential; pass its path, not its contents, to the runner. Tests normally run in `openstack` and call the Lightspeed Service in -`openstack-lightspeed`. `cifmw_test_operator_namespace` selects the test namespace; -the installer also accepts `cifmw_lightspeed_tests_namespace` for its access -binding, which defaults to that test namespace and must match it. +`openstack-lightspeed`. `cifmw_test_operator_namespace` selects the test namespace +and the ServiceAccount namespace used by the installer's access binding; it +defaults to `openstack` for standalone installation. Test access and dummy +prerequisites are grouped after the service reaches Ready. `cifmw_openstack_lightspeed_namespace` selects the application namespace; update the runner's `lightspeed_url` accordingly. The consuming job templates this URL. The parent deployment supplies test-operator, its AnsibleTest API and the diff --git a/hooks/playbooks/install-openstack-lightspeed.yml b/hooks/playbooks/install-openstack-lightspeed.yml index 52ffcca96..ffeaa3ac2 100644 --- a/hooks/playbooks/install-openstack-lightspeed.yml +++ b/hooks/playbooks/install-openstack-lightspeed.yml @@ -18,7 +18,6 @@ hosts: "{{ cifmw_target_hook_host | default('localhost') }}" connection: local vars: - cifmw_lightspeed_tests_namespace: "{{ cifmw_test_operator_namespace | default('openstack') }}" # OpenStack Lightspeed configuration # Note: Using openstack-lightspeed namespace per operator's suggested namespace openstack_lightspeed_namespace: "{{ cifmw_openstack_lightspeed_namespace | default('openstack-lightspeed') }}" @@ -79,68 +78,6 @@ metadata: name: "{{ openstack_lightspeed_namespace }}" - - name: Create ClusterRole for Lightspeed tests - kubernetes.core.k8s: - state: present - definition: - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRole - metadata: - name: "lightspeed-test-role" - rules: - - nonResourceURLs: ["/ls-access"] - verbs: ["get"] - register: _result - retries: 5 - delay: 10 - until: _result is not failed - - - name: Create ClusterRoleBinding for Lightspeed tests - kubernetes.core.k8s: - state: present - definition: - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRoleBinding - metadata: - name: "lightspeed-test-role-binding" - roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: "lightspeed-test-role" - subjects: - - kind: ServiceAccount - name: "default" - namespace: "{{ openstack_lightspeed_namespace }}" - register: _result - retries: 5 - delay: 10 - until: _result is not failed - - - name: Authorize the test Pod's explicitly projected client token - kubernetes.core.k8s: - state: present - definition: - - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRole - metadata: - name: lightspeed-tests-access - rules: - - nonResourceURLs: [/ls-access] - verbs: [get] - - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRoleBinding - metadata: - name: "lightspeed-tests-access-{{ cifmw_lightspeed_tests_namespace }}" - roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: lightspeed-tests-access - subjects: - # The current test-operator uses the namespace's default account. - - kind: ServiceAccount - name: default - namespace: "{{ cifmw_lightspeed_tests_namespace }}" - - name: Create OperatorGroup for OpenStack Lightspeed kubernetes.core.k8s: state: present @@ -473,6 +410,113 @@ failed_when: cert_check.resources | length == 0 when: cifmw_openstack_lightspeed_ca_cert_url is defined or cifmw_openstack_lightspeed_ca_cert is defined + # STEP 4: Create OpenStackLightspeed CR to deploy the service + # Note: Uses configurable parameters for LLM integration + + - name: Create OpenStackLightspeed custom resource + kubernetes.core.k8s: + state: present + definition: + apiVersion: lightspeed.openstack.org/v1beta1 + kind: OpenStackLightspeed + metadata: + name: "{{ cifmw_openstack_lightspeed_cr_name | default('openstacklightspeed-sample') }}" + namespace: "{{ openstack_lightspeed_namespace }}" + spec: + ogx: >- + {{ {'containerImage': cifmw_openstack_lightspeed_ogx_image} + if cifmw_openstack_lightspeed_ogx_image is defined else omit }} + defaultModel: default-model + models: + - name: default-model + llmEndpoint: "{{ cifmw_openstack_lightspeed_llm_endpoint }}" + llmEndpointType: "{{ cifmw_openstack_lightspeed_llm_endpoint_type | default('openai') }}" + modelName: "{{ cifmw_openstack_lightspeed_model_name }}" + llmCredentials: "{{ cifmw_openstack_lightspeed_llm_credentials | default('openstack-lightspeed-apitoken') }}" + tlsCACertBundle: "{{ cifmw_openstack_lightspeed_tls_ca_cert_bundle | default('openstack-lightspeed-certs') if (cifmw_openstack_lightspeed_ca_cert is defined + or cifmw_openstack_lightspeed_ca_cert_url is defined) else omit }}" + + - name: Wait for OpenStackLightspeed CR to be ready + kubernetes.core.k8s_info: + api_version: lightspeed.openstack.org/v1beta1 + kind: OpenStackLightspeed + name: "{{ cifmw_openstack_lightspeed_cr_name | default('openstacklightspeed-sample') }}" + namespace: "{{ openstack_lightspeed_namespace }}" + register: cr_status + until: + - cr_status.resources is defined + - cr_status.resources | length > 0 + - cr_status.resources[0].status is defined + - cr_status.resources[0].status.conditions is defined + - cr_status.resources[0].status.conditions | selectattr('type', 'equalto', 'Ready') | map(attribute='status') | select('equalto', 'True') | list | + length > 0 + retries: "{{ cifmw_openstack_lightspeed_ready_retries | default(120) | int }}" + delay: 10 + + - name: Prepare test-operator access and prerequisites + block: + - name: Create ClusterRole for Lightspeed tests + kubernetes.core.k8s: + state: present + definition: + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: "lightspeed-test-role" + rules: + - nonResourceURLs: ["/ls-access"] + verbs: ["get"] + register: _result + retries: 5 + delay: 10 + until: _result is not failed + + - name: Create ClusterRoleBinding for Lightspeed tests + kubernetes.core.k8s: + state: present + definition: + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: "lightspeed-test-role-binding" + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: "lightspeed-test-role" + subjects: + - kind: ServiceAccount + name: "default" + namespace: "{{ openstack_lightspeed_namespace }}" + register: _result + retries: 5 + delay: 10 + until: _result is not failed + + - name: Authorize the test Pod's explicitly projected client token + kubernetes.core.k8s: + state: present + definition: + - apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: lightspeed-tests-access + rules: + - nonResourceURLs: [/ls-access] + verbs: [get] + - apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: "lightspeed-tests-access-{{ cifmw_test_operator_namespace | default('openstack') }}" + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: lightspeed-tests-access + subjects: + # The current test-operator uses the namespace's default account. + - kind: ServiceAccount + name: default + namespace: "{{ cifmw_test_operator_namespace | default('openstack') }}" + - name: Create test-operator dummy config ConfigMap kubernetes.core.k8s: state: present @@ -540,71 +584,6 @@ delay: 10 until: _result is not failed - # STEP 4: Create OpenStackLightspeed CR to deploy the service - # Note: Uses configurable parameters for LLM integration - - - name: Create OpenStackLightspeed custom resource - kubernetes.core.k8s: - state: present - definition: - apiVersion: lightspeed.openstack.org/v1beta1 - kind: OpenStackLightspeed - metadata: - name: "{{ cifmw_openstack_lightspeed_cr_name | default('openstacklightspeed-sample') }}" - namespace: "{{ openstack_lightspeed_namespace }}" - spec: - ogx: >- - {{ {'containerImage': cifmw_openstack_lightspeed_ogx_image} - if cifmw_openstack_lightspeed_ogx_image is defined else omit }} - defaultModel: default-model - models: - - name: default-model - llmEndpoint: "{{ cifmw_openstack_lightspeed_llm_endpoint }}" - llmEndpointType: "{{ cifmw_openstack_lightspeed_llm_endpoint_type | default('openai') }}" - modelName: "{{ cifmw_openstack_lightspeed_model_name }}" - llmCredentials: "{{ cifmw_openstack_lightspeed_llm_credentials | default('openstack-lightspeed-apitoken') }}" - tlsCACertBundle: "{{ cifmw_openstack_lightspeed_tls_ca_cert_bundle | default('openstack-lightspeed-certs') if (cifmw_openstack_lightspeed_ca_cert is defined - or cifmw_openstack_lightspeed_ca_cert_url is defined) else omit }}" - - - name: Wait for OpenStackLightspeed CR to be ready - kubernetes.core.k8s_info: - api_version: lightspeed.openstack.org/v1beta1 - kind: OpenStackLightspeed - name: "{{ cifmw_openstack_lightspeed_cr_name | default('openstacklightspeed-sample') }}" - namespace: "{{ openstack_lightspeed_namespace }}" - register: cr_status - until: - - cr_status.resources is defined - - cr_status.resources | length > 0 - - cr_status.resources[0].status is defined - - cr_status.resources[0].status.conditions is defined - - cr_status.resources[0].status.conditions | selectattr('type', 'equalto', 'Ready') | map(attribute='status') | select('equalto', 'True') | list | - length > 0 - retries: "{{ cifmw_openstack_lightspeed_ready_retries | default(120) | int }}" - delay: 10 - rescue: - - name: Collect Lightspeed deployment diagnostics - ansible.builtin.command: - argv: - - oc - - get - - pods,deployments,events - - -n - - "{{ openstack_lightspeed_namespace }}" - - -o - - wide - register: lightspeed_diagnostics - changed_when: false - failed_when: false - - - name: Display Lightspeed deployment diagnostics - ansible.builtin.debug: - var: lightspeed_diagnostics.stdout_lines - - - name: Fail the Lightspeed deployment - ansible.builtin.fail: - msg: "OpenStack Lightspeed deployment failed; inspect the failed task and namespace diagnostics above." - - name: Display deployment summary ansible.builtin.debug: msg: From 9b444f34590570012f4c2cbe4e65aeca4f3d98e9 Mon Sep 17 00:00:00 2001 From: malingatembo Date: Sat, 31 Oct 2026 10:37:49 +0100 Subject: [PATCH 13/13] [ci] Reuse the operator's Lightspeed access role Use the operator-managed lightspeed-access ClusterRole for the test Pod's ServiceAccount. Remove duplicate roles and the unused application-account binding while retaining authorization in the configured test namespace. Give the replacement binding a distinct name to avoid changing an existing binding's immutable roleRef. Update the test-access documentation. Related-Issue: #OSPRH-33344 Signed-off-by: malingatembo --- docs/source/usage/lightspeed-testing.md | 6 +- .../install-openstack-lightspeed.yml | 56 ++----------------- 2 files changed, 9 insertions(+), 53 deletions(-) diff --git a/docs/source/usage/lightspeed-testing.md b/docs/source/usage/lightspeed-testing.md index 35ad1ae66..47dfbd7f9 100644 --- a/docs/source/usage/lightspeed-testing.md +++ b/docs/source/usage/lightspeed-testing.md @@ -84,9 +84,9 @@ source archive, ConfigMap transport, generated stage file or bootstrap. The test-operator disables automatic token mounting and currently uses the `default` ServiceAccount in the test namespace. The job explicitly projects that account's token at `/lightspeed-auth/token`; Kubernetes rotates it. The -installer creates the RBAC resources: the `lightspeed-tests-access` `ClusterRole` -and its namespace-specific binding grant that identity GET access to -`/ls-access`. This also grants the permission to other Pods using the same +operator creates the `lightspeed-access` `ClusterRole`, granting GET access to +`/ls-access`. The installer binds that role to the test ServiceAccount with a +namespace-specific `ClusterRoleBinding`. This also grants access to other Pods using the same account in that namespace. Privileged execution does not supply a token. The client token is separate from the mock provider credential; pass its path, not its contents, to the runner. diff --git a/hooks/playbooks/install-openstack-lightspeed.yml b/hooks/playbooks/install-openstack-lightspeed.yml index ffeaa3ac2..b5492197f 100644 --- a/hooks/playbooks/install-openstack-lightspeed.yml +++ b/hooks/playbooks/install-openstack-lightspeed.yml @@ -455,67 +455,23 @@ - name: Prepare test-operator access and prerequisites block: - - name: Create ClusterRole for Lightspeed tests - kubernetes.core.k8s: - state: present - definition: - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRole - metadata: - name: "lightspeed-test-role" - rules: - - nonResourceURLs: ["/ls-access"] - verbs: ["get"] - register: _result - retries: 5 - delay: 10 - until: _result is not failed - - - name: Create ClusterRoleBinding for Lightspeed tests + - name: Authorize the test Pod's explicitly projected client token kubernetes.core.k8s: state: present definition: apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: - name: "lightspeed-test-role-binding" + name: "lightspeed-access-{{ cifmw_test_operator_namespace | default('openstack') }}" roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole - name: "lightspeed-test-role" + name: lightspeed-access subjects: + # The current test-operator uses the namespace's default account. - kind: ServiceAccount - name: "default" - namespace: "{{ openstack_lightspeed_namespace }}" - register: _result - retries: 5 - delay: 10 - until: _result is not failed - - - name: Authorize the test Pod's explicitly projected client token - kubernetes.core.k8s: - state: present - definition: - - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRole - metadata: - name: lightspeed-tests-access - rules: - - nonResourceURLs: [/ls-access] - verbs: [get] - - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRoleBinding - metadata: - name: "lightspeed-tests-access-{{ cifmw_test_operator_namespace | default('openstack') }}" - roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: lightspeed-tests-access - subjects: - # The current test-operator uses the namespace's default account. - - kind: ServiceAccount - name: default - namespace: "{{ cifmw_test_operator_namespace | default('openstack') }}" + name: default + namespace: "{{ cifmw_test_operator_namespace | default('openstack') }}" - name: Create test-operator dummy config ConfigMap kubernetes.core.k8s: