From c90c2444be4687100ef91d5c5bc174c731c7171e Mon Sep 17 00:00:00 2001 From: Leon Kladnitsky Date: Tue, 2 Jun 2026 20:40:21 +0300 Subject: [PATCH] CNV-87992: Add hot-cluster CI infrastructure Port hot-cluster CI approach from kubevirt-plugin (CNV-74265) for running E2E Cypress tests on a persistent OpenShift cluster via GitHub Actions + ARC self-hosted runners. Includes composite actions for test environment lifecycle, ci-scripts for cluster health checks and test cleanup, and Helm charts for in-cluster console + plugin deployment. Co-authored-by: Cursor --- .dockerignore | 17 ++ .env.example | 5 + .github/actions/ci-env-release/action.yml | 58 +++++ .github/actions/ci-env-request/action.yml | 124 +++++++++ .github/workflows/e2e.yml | 47 ++++ .gitignore | 2 + ci-scripts/_cluster-helpers.sh | 75 ++++++ ci-scripts/check-cluster-health.sh | 97 +++++++ ci-scripts/helm/ci-env-controller/Chart.yaml | 6 + .../scripts/ci-env-controller.sh | 175 +++++++++++++ .../ci-env-controller/templates/NOTES.txt | 16 ++ .../ci-env-controller/templates/_helpers.tpl | 5 + .../templates/clusterrole-console.yaml | 57 +++++ .../templates/clusterrole-controller.yaml | 39 +++ .../templates/clusterrole-test-runner.yaml | 34 +++ .../templates/clusterrolebinding.yaml | 14 + .../templates/configmap-script.yaml | 9 + .../templates/deployment.yaml | 52 ++++ .../templates/role-trigger.yaml | 10 + .../templates/rolebinding-runner.yaml | 16 ++ .../templates/serviceaccount.yaml | 6 + ci-scripts/helm/ci-env-controller/values.yaml | 11 + ci-scripts/helm/ci-test-stack/Chart.yaml | 6 + .../helm/ci-test-stack/templates/NOTES.txt | 10 + .../helm/ci-test-stack/templates/_helpers.tpl | 29 +++ .../templates/console-clusterrolebinding.yaml | 14 + .../templates/console-configmap.yaml | 9 + .../templates/console-deployment.yaml | 72 ++++++ .../templates/console-route.yaml | 17 ++ .../ci-test-stack/templates/console-sa.yaml | 16 ++ .../templates/console-service.yaml | 13 + .../templates/plugin-configmap-nginx.yaml | 39 +++ .../templates/plugin-deployment.yaml | 46 ++++ .../templates/plugin-service.yaml | 13 + .../templates/runner-rolebinding.yaml | 16 ++ ci-scripts/helm/ci-test-stack/values.yaml | 19 ++ ci-scripts/nginx-9080.conf | 31 +++ ci-scripts/nginx-9443.conf | 35 +++ ci-scripts/resolve-console-image.sh | 39 +++ ci-scripts/start-console.sh | 72 ++++++ ci-scripts/start-plugin-container.sh | 76 ++++++ ci-scripts/test-cleanup.sh | 28 ++ cleanup.sh | 22 ++ integration-tests/support/login.ts | 4 +- integration-tests/tests/example-page.cy.ts | 2 +- package-lock.json | 14 + package.json | 15 +- research-flakiness.sh | 120 +++++++++ setup.sh | 43 ++++ test-cypress.sh | 38 +++ ui-tests-cy/.eslintrc | 17 ++ ui-tests-cy/MIGRATION.md | 55 ++++ ui-tests-cy/PLAN.md | 242 ++++++++++++++++++ ui-tests-cy/cypress.config.js | 31 +++ ui-tests-cy/plugins/index.ts | 31 +++ ui-tests-cy/reporter-config.json | 14 + ui-tests-cy/support/commands.ts | 48 ++++ ui-tests-cy/support/index.ts | 18 ++ ui-tests-cy/support/login.ts | 60 +++++ ui-tests-cy/support/nav.ts | 28 ++ ui-tests-cy/support/selectors.ts | 81 ++++++ ui-tests-cy/tests/all.cy.ts | 10 + ui-tests-cy/tests/networking/ingresses.cy.ts | 20 ++ ui-tests-cy/tests/networking/nad-bridge.cy.ts | 9 + .../tests/networking/nad-localnet.cy.ts | 14 + ui-tests-cy/tests/networking/nad-ovn.cy.ts | 9 + .../tests/networking/net-policies.cy.ts | 31 +++ ui-tests-cy/tests/networking/routes.cy.ts | 32 +++ ui-tests-cy/tests/networking/services.cy.ts | 19 ++ ui-tests-cy/tests/networking/udn.cy.ts | 46 ++++ ui-tests-cy/tests/setup/login.cy.ts | 5 + ui-tests-cy/tests/setup/visit-pages.cy.ts | 35 +++ ui-tests-cy/tsconfig.json | 9 + ui-tests-cy/utils/const/base.ts | 23 ++ ui-tests-cy/utils/const/nad.ts | 28 ++ ui-tests-cy/utils/types/nad.ts | 11 + ui-tests-cy/views/actions.ts | 12 + ui-tests-cy/views/nad.ts | 77 ++++++ ui-tests-cy/views/selector-common.ts | 14 + ui-tests-cy/views/udn.ts | 39 +++ 80 files changed, 2792 insertions(+), 9 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/actions/ci-env-release/action.yml create mode 100644 .github/actions/ci-env-request/action.yml create mode 100644 .github/workflows/e2e.yml create mode 100755 ci-scripts/_cluster-helpers.sh create mode 100755 ci-scripts/check-cluster-health.sh create mode 100644 ci-scripts/helm/ci-env-controller/Chart.yaml create mode 100644 ci-scripts/helm/ci-env-controller/scripts/ci-env-controller.sh create mode 100644 ci-scripts/helm/ci-env-controller/templates/NOTES.txt create mode 100644 ci-scripts/helm/ci-env-controller/templates/_helpers.tpl create mode 100644 ci-scripts/helm/ci-env-controller/templates/clusterrole-console.yaml create mode 100644 ci-scripts/helm/ci-env-controller/templates/clusterrole-controller.yaml create mode 100644 ci-scripts/helm/ci-env-controller/templates/clusterrole-test-runner.yaml create mode 100644 ci-scripts/helm/ci-env-controller/templates/clusterrolebinding.yaml create mode 100644 ci-scripts/helm/ci-env-controller/templates/configmap-script.yaml create mode 100644 ci-scripts/helm/ci-env-controller/templates/deployment.yaml create mode 100644 ci-scripts/helm/ci-env-controller/templates/role-trigger.yaml create mode 100644 ci-scripts/helm/ci-env-controller/templates/rolebinding-runner.yaml create mode 100644 ci-scripts/helm/ci-env-controller/templates/serviceaccount.yaml create mode 100644 ci-scripts/helm/ci-env-controller/values.yaml create mode 100644 ci-scripts/helm/ci-test-stack/Chart.yaml create mode 100644 ci-scripts/helm/ci-test-stack/templates/NOTES.txt create mode 100644 ci-scripts/helm/ci-test-stack/templates/_helpers.tpl create mode 100644 ci-scripts/helm/ci-test-stack/templates/console-clusterrolebinding.yaml create mode 100644 ci-scripts/helm/ci-test-stack/templates/console-configmap.yaml create mode 100644 ci-scripts/helm/ci-test-stack/templates/console-deployment.yaml create mode 100644 ci-scripts/helm/ci-test-stack/templates/console-route.yaml create mode 100644 ci-scripts/helm/ci-test-stack/templates/console-sa.yaml create mode 100644 ci-scripts/helm/ci-test-stack/templates/console-service.yaml create mode 100644 ci-scripts/helm/ci-test-stack/templates/plugin-configmap-nginx.yaml create mode 100644 ci-scripts/helm/ci-test-stack/templates/plugin-deployment.yaml create mode 100644 ci-scripts/helm/ci-test-stack/templates/plugin-service.yaml create mode 100644 ci-scripts/helm/ci-test-stack/templates/runner-rolebinding.yaml create mode 100644 ci-scripts/helm/ci-test-stack/values.yaml create mode 100644 ci-scripts/nginx-9080.conf create mode 100644 ci-scripts/nginx-9443.conf create mode 100755 ci-scripts/resolve-console-image.sh create mode 100755 ci-scripts/start-console.sh create mode 100755 ci-scripts/start-plugin-container.sh create mode 100755 ci-scripts/test-cleanup.sh create mode 100755 cleanup.sh create mode 100755 research-flakiness.sh create mode 100755 setup.sh create mode 100755 test-cypress.sh create mode 100644 ui-tests-cy/.eslintrc create mode 100644 ui-tests-cy/MIGRATION.md create mode 100644 ui-tests-cy/PLAN.md create mode 100644 ui-tests-cy/cypress.config.js create mode 100644 ui-tests-cy/plugins/index.ts create mode 100644 ui-tests-cy/reporter-config.json create mode 100644 ui-tests-cy/support/commands.ts create mode 100644 ui-tests-cy/support/index.ts create mode 100644 ui-tests-cy/support/login.ts create mode 100644 ui-tests-cy/support/nav.ts create mode 100644 ui-tests-cy/support/selectors.ts create mode 100644 ui-tests-cy/tests/all.cy.ts create mode 100644 ui-tests-cy/tests/networking/ingresses.cy.ts create mode 100644 ui-tests-cy/tests/networking/nad-bridge.cy.ts create mode 100644 ui-tests-cy/tests/networking/nad-localnet.cy.ts create mode 100644 ui-tests-cy/tests/networking/nad-ovn.cy.ts create mode 100644 ui-tests-cy/tests/networking/net-policies.cy.ts create mode 100644 ui-tests-cy/tests/networking/routes.cy.ts create mode 100644 ui-tests-cy/tests/networking/services.cy.ts create mode 100644 ui-tests-cy/tests/networking/udn.cy.ts create mode 100644 ui-tests-cy/tests/setup/login.cy.ts create mode 100644 ui-tests-cy/tests/setup/visit-pages.cy.ts create mode 100644 ui-tests-cy/tsconfig.json create mode 100644 ui-tests-cy/utils/const/base.ts create mode 100644 ui-tests-cy/utils/const/nad.ts create mode 100644 ui-tests-cy/utils/types/nad.ts create mode 100644 ui-tests-cy/views/actions.ts create mode 100644 ui-tests-cy/views/nad.ts create mode 100644 ui-tests-cy/views/selector-common.ts create mode 100644 ui-tests-cy/views/udn.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..00cfc778 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +node_modules +dist +.git +.idea +.vscode +.devcontainer +.github +ci-scripts +cypress +integration-tests +problem +*.md +.env +.gitignore +.dockerignore +.prettierignore +.eslintignore diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..9550c3d1 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +BRIDGE_BASE_ADDRESS=https://console-openshift-console.apps.your-cluster.example.com +BRIDGE_KUBEADMIN_PASSWORD=your-password +TEST_NS=cy-test-ns +UDN_NS=udn-test-ns +HIDE_XHR=true diff --git a/.github/actions/ci-env-release/action.yml b/.github/actions/ci-env-release/action.yml new file mode 100644 index 00000000..861b6fa4 --- /dev/null +++ b/.github/actions/ci-env-release/action.yml @@ -0,0 +1,58 @@ +name: Release CI Test Environment +description: > + Signal the ci-env-controller to tear down a test environment by patching + the trigger ConfigMap to desired-state=absent, then wait for cleanup to + complete and delete the ConfigMap. + +inputs: + configmap-name: + description: Name of the trigger ConfigMap + required: true + ci-env-namespace: + description: Namespace where ci-env-controller runs + default: ci-env + timeout: + description: Max seconds to wait for cleanup to complete + default: '300' + +runs: + using: composite + steps: + - name: Release environment + shell: bash + env: + CM_NAME: ${{ inputs.configmap-name }} + CM_NS: ${{ inputs.ci-env-namespace }} + TIMEOUT: ${{ inputs.timeout }} + run: | + if ! oc get configmap "${CM_NAME}" -n "${CM_NS}" &>/dev/null; then + echo "ConfigMap ${CM_NS}/${CM_NAME} not found, nothing to clean up." + exit 0 + fi + + oc patch configmap "${CM_NAME}" -n "${CM_NS}" \ + --type merge -p '{"data":{"desired-state":"absent"}}' + + echo "Waiting for controller to clean up..." + INTERVAL=5 + ELAPSED=0 + + while true; do + STATUS="$(oc get configmap "${CM_NAME}" -n "${CM_NS}" \ + -o jsonpath='{.data.status}' 2>/dev/null || echo "")" + + if [[ "${STATUS}" == "cleaned" ]]; then + echo "Cleanup complete." + break + fi + + if (( ELAPSED >= TIMEOUT )); then + echo "::warning::Timed out waiting for controller cleanup (status=${STATUS})" + break + fi + + sleep "${INTERVAL}" + ELAPSED=$(( ELAPSED + INTERVAL )) + done + + oc delete configmap "${CM_NAME}" -n "${CM_NS}" 2>/dev/null || true diff --git a/.github/actions/ci-env-request/action.yml b/.github/actions/ci-env-request/action.yml new file mode 100644 index 00000000..9b2f14cb --- /dev/null +++ b/.github/actions/ci-env-request/action.yml @@ -0,0 +1,124 @@ +name: Request CI Test Environment +description: > + Create a trigger ConfigMap for the ci-env-controller and wait until the + test environment (namespace, console, plugin) is provisioned and ready. + +inputs: + plugin-image: + description: Plugin container image to deploy + required: true + test-namespace: + description: Kubernetes namespace for the test environment + required: true + configmap-name: + description: Name of the trigger ConfigMap + required: true + ci-env-namespace: + description: Namespace where ci-env-controller runs + default: ci-env + timeout: + description: Max seconds to wait for environment to become ready + default: '360' + +outputs: + bridge-base-address: + description: In-cluster URL for the console bridge + value: ${{ steps.wait.outputs.bridge-base-address }} + console-route: + description: External HTTPS route for the console + value: ${{ steps.wait.outputs.console-route }} + +runs: + using: composite + steps: + - name: Create trigger ConfigMap + shell: bash + run: | + cat </dev/null || echo "")" + + case "${STATUS}" in + ready) + echo "Environment is ready." + break + ;; + error) + ERR_MSG="$(oc get configmap "${CM_NAME}" -n "${CM_NS}" \ + -o jsonpath='{.data.error-message}' 2>/dev/null || echo "unknown error")" + echo "::error::Environment provisioning failed: ${ERR_MSG}" + exit 1 + ;; + *) + if (( ELAPSED >= TIMEOUT )); then + echo "::error::Timed out waiting for environment (status=${STATUS:-pending})" + exit 1 + fi + echo " status=${STATUS:-pending} (${ELAPSED}s / ${TIMEOUT}s)..." + sleep "${INTERVAL}" + ELAPSED=$(( ELAPSED + INTERVAL )) + ;; + esac + done + + BRIDGE_BASE_ADDRESS="$(oc get configmap "${CM_NAME}" -n "${CM_NS}" \ + -o jsonpath='{.data.bridge-base-address}')" + CONSOLE_ROUTE="$(oc get configmap "${CM_NAME}" -n "${CM_NS}" \ + -o jsonpath='{.data.console-route}' 2>/dev/null || echo "")" + + echo "bridge-base-address=${BRIDGE_BASE_ADDRESS}" >> "${GITHUB_OUTPUT}" + echo "console-route=${CONSOLE_ROUTE}" >> "${GITHUB_OUTPUT}" + + - name: Write job summary + shell: bash + env: + CM_NAME: ${{ inputs.configmap-name }} + CM_NS: ${{ inputs.ci-env-namespace }} + PLUGIN_IMAGE: ${{ inputs.plugin-image }} + TEST_NS: ${{ inputs.test-namespace }} + BRIDGE: ${{ steps.wait.outputs.bridge-base-address }} + ROUTE: ${{ steps.wait.outputs.console-route }} + run: | + { + echo "
CI Test Environment" + echo "" + echo "| Input Parameter | Value |" + echo "|------|-------|" + echo "| ConfigMap | \`${CM_NS}/${CM_NAME}\` |" + echo "| Plugin image | \`${PLUGIN_IMAGE}\` |" + echo "| Test namespace | \`${TEST_NS}\` |" + echo "" + echo "| Output Parameter | Value |" + echo "|------|-------|" + echo "| Bridge base address | \`${BRIDGE}\` |" + echo "| Console route | \`${ROUTE}\` |" + echo "" + echo "
" + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..b9c2d6e1 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,47 @@ +name: E2E Tests + +on: + pull_request: + branches: [main, release-*] + workflow_dispatch: + +concurrency: + group: e2e-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + +jobs: + e2e: + name: Cypress E2E + runs-on: ubuntu-latest + timeout-minutes: 30 + + env: + BRIDGE_BASE_ADDRESS: ${{ secrets.CONSOLE_URL }} + BRIDGE_KUBEADMIN_PASSWORD: ${{ secrets.KUBEADMIN_PASSWORD }} + BRIDGE_E2E_BROWSER_NAME: electron + TEST_NS: ${{ secrets.TEST_NS || 'cy-test-ns' }} + UDN_NS: udn-test-ns + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 18 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run E2E tests + run: npm run test-e2e + + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-results + path: cypress/gui-test-screenshots/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index bde351d5..ceb70bf2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ integration-tests/screenshots integration-tests/.DS_Store yarn-error.log .DS_Store +cypress/gui-test-screenshots/ +.env diff --git a/ci-scripts/_cluster-helpers.sh b/ci-scripts/_cluster-helpers.sh new file mode 100755 index 00000000..f0b55989 --- /dev/null +++ b/ci-scripts/_cluster-helpers.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# +# Resolve CLI binary download URLs from a live OpenShift cluster. +# +# Source this file; do not execute it directly. +# source "$(dirname "${BASH_SOURCE[0]}")/_cluster-helpers.sh" +# +# After sourcing, call: +# resolve_cli_downloads # populates OC_URL +# resolve_oc_version # populates OC_VERSION (if not already set) +# resolve_internal_registry # populates INTERNAL_REGISTRY + +verify_oc() { + if ! oc get clusterversion version &>/dev/null; then + echo "ERROR: OpenShift cluster required (clusterversion.version not found)." + exit 1 + fi +} + +_route_url_to_internal() { + local url="${1}" + [[ -z "${url}" ]] && return + local host path route_info ns svc svc_port + host=$(echo "${url}" | sed -E 's|https://([^/]+).*|\1|') + path=$(echo "${url}" | sed -E 's|https://[^/]+(/.*)?|\1|' || echo '/') + route_info=$(echo "${_ALL_ROUTES_JSON}" \ + | jq -r --arg h "${host}" \ + '.items[] | select(.spec.host == $h) | "\(.metadata.namespace) \(.spec.to.name)"' \ + | head -1) + if [[ -n "${route_info}" ]]; then + read -r ns svc <<< "${route_info}" + svc_port=$(oc get service "${svc}" -n "${ns}" \ + -o jsonpath='{.spec.ports[0].port}' 2>/dev/null || echo "8080") + echo "http://${svc}.${ns}.svc.cluster.local:${svc_port}${path}" + else + echo "${url}" + fi +} + +resolve_oc_version() { + if [[ -n "${OC_VERSION:-}" ]]; then + return + fi + OC_VERSION=$(oc version --output json 2>/dev/null \ + | jq -r '.openshiftVersion | split(".") | .[0:2] | join(".") // empty') || true + OC_VERSION="${OC_VERSION:-4.20}" +} + +resolve_internal_registry() { + INTERNAL_REGISTRY="$(oc get image.config.openshift.io/cluster \ + -o jsonpath='{.status.internalRegistryHostname}' 2>/dev/null \ + || echo 'image-registry.openshift-image-registry.svc:5000')" +} + +resolve_cli_downloads() { + OC_URL="" + + if ! command -v jq &>/dev/null; then + return + fi + + local cli_json + cli_json=$(oc get consoleclidownload -o json 2>/dev/null || true) + if [[ -z "${cli_json}" ]]; then + return + fi + + OC_URL=$(echo "${cli_json}" \ + | jq -r '.items[].spec.links[] | select(.text | test("oc.*linux.*x86_64|oc.*linux.*amd64"; "i")) | .href' \ + | head -1) + + _ALL_ROUTES_JSON=$(oc get route --all-namespaces -o json 2>/dev/null || true) + [[ -n "${OC_URL}" ]] && OC_URL=$(_route_url_to_internal "${OC_URL}") + unset _ALL_ROUTES_JSON +} diff --git a/ci-scripts/check-cluster-health.sh b/ci-scripts/check-cluster-health.sh new file mode 100755 index 00000000..b55a87f2 --- /dev/null +++ b/ci-scripts/check-cluster-health.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# +# Check the health of the hot cluster: API server, nodes, +# ARC runner scale set, storage, and console route. +# +# Returns exit code 0 if all checks pass, non-zero otherwise. + +set -uo pipefail + +ARC_RUNNERS_NS="${ARC_RUNNERS_NS:-arc-runners}" +FAILURES=0 + +check() { + local name="$1" + shift + echo -n "Checking ${name}... " + if "$@"; then + echo "OK" + else + echo "FAILED" + FAILURES=$((FAILURES + 1)) + fi +} + +echo "=== Cluster Health Check ===" +echo "" + +check "API server reachability" oc cluster-info + +check "node readiness" bash -c ' + ready_nodes=$(oc get nodes --no-headers 2>/dev/null | grep -c " Ready") + if [[ "${ready_nodes}" -ge 1 ]]; then + echo " ${ready_nodes} node(s) Ready" + exit 0 + else + echo " No nodes in Ready state" + exit 1 + fi +' + +check "ARC AutoscalingRunnerSet in ${ARC_RUNNERS_NS}" bash -c " + if ! oc get namespace '${ARC_RUNNERS_NS}' &>/dev/null; then + echo ' Namespace ${ARC_RUNNERS_NS} does not exist' + exit 1 + fi + rs_count=\$(oc get autoscalingrunnersets -n '${ARC_RUNNERS_NS}' --no-headers 2>/dev/null | wc -l) + if [[ \"\${rs_count}\" -ge 1 ]]; then + echo \" \${rs_count} AutoscalingRunnerSet(s) found\" + exit 0 + else + echo ' No AutoscalingRunnerSets found in ${ARC_RUNNERS_NS}' + exit 1 + fi +" + +check "ARC listener pod in ${ARC_RUNNERS_NS}" bash -c " + running=\$(oc get pods -n '${ARC_RUNNERS_NS}' --no-headers 2>/dev/null | grep -c 'Running') + if [[ \"\${running}\" -ge 1 ]]; then + echo \" \${running} Running pod(s) (listener/controller)\" + exit 0 + else + echo ' No Running pods in ${ARC_RUNNERS_NS}' + exit 1 + fi +" + +check "default StorageClass" bash -c ' + default_sc=$(oc get storageclass -o jsonpath="{.items[?(@.metadata.annotations.storageclass\.kubernetes\.io/is-default-class==\"true\")].metadata.name}" 2>/dev/null) + if [[ -n "${default_sc}" ]]; then + echo " Default StorageClass: ${default_sc}" + exit 0 + else + echo " No default StorageClass found" + exit 1 + fi +' + +check "console route accessible" bash -c ' + console_url=$(oc get consoles.config.openshift.io cluster -o jsonpath="{.status.consoleURL}" 2>/dev/null) + if [[ -n "${console_url}" ]]; then + echo " Console URL: ${console_url}" + exit 0 + else + echo " Console URL not found" + exit 1 + fi +' + +echo "" +echo "=== Health Check Summary ===" +if [[ ${FAILURES} -eq 0 ]]; then + echo "All checks passed" + exit 0 +else + echo "${FAILURES} check(s) FAILED" + exit 1 +fi diff --git a/ci-scripts/helm/ci-env-controller/Chart.yaml b/ci-scripts/helm/ci-env-controller/Chart.yaml new file mode 100644 index 00000000..2b6bffc1 --- /dev/null +++ b/ci-scripts/helm/ci-env-controller/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: ci-env-controller +description: Controller that watches ConfigMaps and reconciles test environments for networking-console-plugin CI +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/ci-scripts/helm/ci-env-controller/scripts/ci-env-controller.sh b/ci-scripts/helm/ci-env-controller/scripts/ci-env-controller.sh new file mode 100644 index 00000000..25a7529f --- /dev/null +++ b/ci-scripts/helm/ci-env-controller/scripts/ci-env-controller.sh @@ -0,0 +1,175 @@ +#!/bin/bash +# +# ci-env-controller — watches trigger ConfigMaps and reconciles test environments. +# +# Each trigger ConfigMap has: +# data.desired-state: "present" | "absent" +# data.plugin-image: container image to deploy +# data.test-namespace: namespace for the test stack +# +# The controller provisions (helm install) or tears down (helm uninstall) +# based on the desired state, then updates the ConfigMap with status. +# +set -uo pipefail + +RECONCILE_INTERVAL="${RECONCILE_INTERVAL:-15}" +REAP_AFTER_MINUTES="${REAP_AFTER_MINUTES:-120}" +CI_ENV_NAMESPACE="${CI_ENV_NAMESPACE:-ci-env}" +CONSOLE_IMAGE_REGISTRY="${CONSOLE_IMAGE_REGISTRY:-quay.io/openshift/origin-console}" +LABEL_SELECTOR="ci.networking-console-plugin/type=test-environment" + +resolve_console_image() { + local version + version="$(oc get clusterversion version -o jsonpath='{.status.desired.version}' 2>/dev/null || true)" + if [[ -z "${version}" ]]; then + echo "${CONSOLE_IMAGE_REGISTRY}:latest" + return + fi + local major minor + IFS='.' read -r major minor _rest <<< "${version}" + echo "${CONSOLE_IMAGE_REGISTRY}:${major}.${minor}" +} + +resolve_api_server() { + oc whoami --show-server 2>/dev/null || echo "https://kubernetes.default.svc" +} + +patch_cm() { + local cm_name="$1" + shift + oc patch configmap "${cm_name}" -n "${CI_ENV_NAMESPACE}" --type merge -p "$@" 2>/dev/null || true +} + +provision_env() { + local cm_name="$1" + local plugin_image="$2" + local test_ns="$3" + + echo "[provision] ${cm_name}: plugin=${plugin_image}, ns=${test_ns}" + patch_cm "${cm_name}" "{\"data\":{\"status\":\"provisioning\"}}" + + local console_image api_server helm_release + console_image="$(resolve_console_image)" + api_server="$(resolve_api_server)" + helm_release="${cm_name}" + + oc get namespace "${test_ns}" 2>/dev/null || oc create namespace "${test_ns}" + + if helm status "${helm_release}" -n "${test_ns}" &>/dev/null; then + echo "[provision] Upgrading existing release ${helm_release}" + helm upgrade "${helm_release}" /charts/ci-test-stack -n "${test_ns}" \ + --set pluginImage="${plugin_image}" \ + --set consoleImage="${console_image}" \ + --set apiServerURL="${api_server}" \ + --set runnerServiceAccount="${RUNNER_SERVICE_ACCOUNT:-}" \ + --wait --timeout 300s + else + echo "[provision] Installing new release ${helm_release}" + helm install "${helm_release}" /charts/ci-test-stack -n "${test_ns}" \ + --set pluginImage="${plugin_image}" \ + --set consoleImage="${console_image}" \ + --set apiServerURL="${api_server}" \ + --set runnerServiceAccount="${RUNNER_SERVICE_ACCOUNT:-}" \ + --wait --timeout 300s + fi + + if [[ $? -ne 0 ]]; then + patch_cm "${cm_name}" "{\"data\":{\"status\":\"error\",\"error-message\":\"helm install/upgrade failed\"}}" + return 1 + fi + + local bridge_url console_route + bridge_url="http://${helm_release}-console.${test_ns}.svc.cluster.local:9000" + console_route="$(oc get route "${helm_release}-console" -n "${test_ns}" \ + -o jsonpath='{.spec.host}' 2>/dev/null || echo "")" + + patch_cm "${cm_name}" "{\"data\":{\"status\":\"ready\",\"bridge-base-address\":\"${bridge_url}\",\"console-route\":\"${console_route}\",\"helm-release\":\"${helm_release}\"}}" + echo "[provision] ${cm_name}: ready (bridge=${bridge_url})" +} + +teardown_env() { + local cm_name="$1" + local test_ns="$2" + local helm_release="${cm_name}" + + echo "[teardown] ${cm_name}: ns=${test_ns}" + patch_cm "${cm_name}" "{\"data\":{\"status\":\"cleaning\"}}" + + helm uninstall "${helm_release}" -n "${test_ns}" --wait --timeout 120s 2>/dev/null || true + oc delete namespace "${test_ns}" --ignore-not-found --wait=false 2>/dev/null || true + + patch_cm "${cm_name}" "{\"data\":{\"status\":\"cleaned\"}}" + echo "[teardown] ${cm_name}: cleaned" +} + +reconcile_one() { + local cm_name="$1" + local desired_state plugin_image test_ns status + + desired_state="$(oc get configmap "${cm_name}" -n "${CI_ENV_NAMESPACE}" \ + -o jsonpath='{.data.desired-state}' 2>/dev/null || echo "")" + plugin_image="$(oc get configmap "${cm_name}" -n "${CI_ENV_NAMESPACE}" \ + -o jsonpath='{.data.plugin-image}' 2>/dev/null || echo "")" + test_ns="$(oc get configmap "${cm_name}" -n "${CI_ENV_NAMESPACE}" \ + -o jsonpath='{.data.test-namespace}' 2>/dev/null || echo "")" + status="$(oc get configmap "${cm_name}" -n "${CI_ENV_NAMESPACE}" \ + -o jsonpath='{.data.status}' 2>/dev/null || echo "")" + + case "${desired_state}" in + present) + if [[ "${status}" == "ready" || "${status}" == "provisioning" ]]; then + return + fi + if [[ -z "${plugin_image}" || -z "${test_ns}" ]]; then + patch_cm "${cm_name}" "{\"data\":{\"status\":\"error\",\"error-message\":\"missing plugin-image or test-namespace\"}}" + return + fi + provision_env "${cm_name}" "${plugin_image}" "${test_ns}" + ;; + absent) + if [[ "${status}" == "cleaned" || "${status}" == "cleaning" ]]; then + return + fi + teardown_env "${cm_name}" "${test_ns}" + ;; + esac +} + +reap_stale() { + local now_epoch cm_list cm_name created_str created_epoch age_minutes + now_epoch=$(date +%s) + + cm_list="$(oc get configmap -n "${CI_ENV_NAMESPACE}" -l "${LABEL_SELECTOR}" \ + -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.metadata.creationTimestamp}{"\n"}{end}' 2>/dev/null || true)" + + while IFS=$'\t' read -r cm_name created_str; do + [[ -z "${cm_name}" ]] && continue + created_epoch=$(date -d "${created_str}" +%s 2>/dev/null || echo "0") + age_minutes=$(( (now_epoch - created_epoch) / 60 )) + + if (( age_minutes >= REAP_AFTER_MINUTES )); then + echo "[reap] ${cm_name} is ${age_minutes}m old (threshold: ${REAP_AFTER_MINUTES}m), tearing down" + local test_ns + test_ns="$(oc get configmap "${cm_name}" -n "${CI_ENV_NAMESPACE}" \ + -o jsonpath='{.data.test-namespace}' 2>/dev/null || echo "")" + teardown_env "${cm_name}" "${test_ns}" + oc delete configmap "${cm_name}" -n "${CI_ENV_NAMESPACE}" 2>/dev/null || true + fi + done <<< "${cm_list}" +} + +echo "ci-env-controller starting (ns=${CI_ENV_NAMESPACE}, interval=${RECONCILE_INTERVAL}s, reap=${REAP_AFTER_MINUTES}m)" + +while true; do + CM_NAMES="$(oc get configmap -n "${CI_ENV_NAMESPACE}" -l "${LABEL_SELECTOR}" \ + -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' 2>/dev/null || true)" + + while IFS= read -r cm_name; do + [[ -z "${cm_name}" ]] && continue + reconcile_one "${cm_name}" || true + done <<< "${CM_NAMES}" + + reap_stale || true + + sleep "${RECONCILE_INTERVAL}" +done diff --git a/ci-scripts/helm/ci-env-controller/templates/NOTES.txt b/ci-scripts/helm/ci-env-controller/templates/NOTES.txt new file mode 100644 index 00000000..49f56367 --- /dev/null +++ b/ci-scripts/helm/ci-env-controller/templates/NOTES.txt @@ -0,0 +1,16 @@ +ci-env-controller deployed: {{ .Release.Name }} + +The controller watches for ConfigMaps in {{ .Release.Namespace }} with label: + ci.networking-console-plugin/type=test-environment + +To trigger a test environment manually: + oc create configmap my-test-env -n {{ .Release.Namespace }} \ + --from-literal=desired-state=present \ + --from-literal=plugin-image=YOUR_IMAGE \ + --from-literal=test-namespace=YOUR_NS + oc label configmap my-test-env -n {{ .Release.Namespace }} \ + ci.networking-console-plugin/type=test-environment + +To tear down: + oc patch configmap my-test-env -n {{ .Release.Namespace }} \ + --type merge -p '{"data":{"desired-state":"absent"}}' diff --git a/ci-scripts/helm/ci-env-controller/templates/_helpers.tpl b/ci-scripts/helm/ci-env-controller/templates/_helpers.tpl new file mode 100644 index 00000000..9ef032a8 --- /dev/null +++ b/ci-scripts/helm/ci-env-controller/templates/_helpers.tpl @@ -0,0 +1,5 @@ +{{- define "ci-env-controller.labels" -}} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/instance: {{ .Release.Name }} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }} +{{- end }} diff --git a/ci-scripts/helm/ci-env-controller/templates/clusterrole-console.yaml b/ci-scripts/helm/ci-env-controller/templates/clusterrole-console.yaml new file mode 100644 index 00000000..54b6b60b --- /dev/null +++ b/ci-scripts/helm/ci-env-controller/templates/clusterrole-console.yaml @@ -0,0 +1,57 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ .Release.Name }}-console + labels: + {{- include "ci-env-controller.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["namespaces", "pods", "services", "configmaps", "secrets", "serviceaccounts", "endpoints", "persistentvolumeclaims", "events", "replicationcontrollers"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["nodes"] + verbs: ["get", "list", "watch"] + - apiGroups: ["apps"] + resources: ["deployments", "replicasets", "statefulsets", "daemonsets"] + verbs: ["get", "list", "watch"] + - apiGroups: ["route.openshift.io"] + resources: ["routes"] + verbs: ["get", "list", "watch"] + - apiGroups: ["networking.k8s.io"] + resources: ["networkpolicies", "ingresses"] + verbs: ["get", "list", "watch"] + - apiGroups: ["k8s.cni.cncf.io"] + resources: ["network-attachment-definitions", "multi-networkpolicies"] + verbs: ["get", "list", "watch"] + - apiGroups: ["k8s.ovn.org"] + resources: ["userdefinednetworks", "clusteruserdefinednetworks"] + verbs: ["get", "list", "watch"] + - apiGroups: ["nmstate.io"] + resources: ["nodenetworkconfigurationpolicies"] + verbs: ["get", "list", "watch"] + - apiGroups: ["config.openshift.io"] + resources: ["clusterversions", "consoles", "infrastructures", "ingresses"] + verbs: ["get", "list", "watch"] + - apiGroups: ["console.openshift.io"] + resources: ["consoleplugins", "consoleclidownloads"] + verbs: ["get", "list", "watch"] + - apiGroups: ["operator.openshift.io"] + resources: ["networks"] + verbs: ["get", "list", "watch"] + - apiGroups: ["rbac.authorization.k8s.io"] + resources: ["clusterroles", "clusterrolebindings", "roles", "rolebindings"] + verbs: ["get", "list", "watch"] + - apiGroups: ["storage.k8s.io"] + resources: ["storageclasses"] + verbs: ["get", "list", "watch"] + - apiGroups: ["apiextensions.k8s.io"] + resources: ["customresourcedefinitions"] + verbs: ["get", "list", "watch"] + - apiGroups: ["authorization.k8s.io"] + resources: ["selfsubjectaccessreviews", "selfsubjectrulesreviews"] + verbs: ["create"] + - apiGroups: ["authentication.k8s.io"] + resources: ["tokenreviews"] + verbs: ["create"] + - nonResourceURLs: ["/api", "/api/*", "/apis", "/apis/*", "/version"] + verbs: ["get"] diff --git a/ci-scripts/helm/ci-env-controller/templates/clusterrole-controller.yaml b/ci-scripts/helm/ci-env-controller/templates/clusterrole-controller.yaml new file mode 100644 index 00000000..304c7f89 --- /dev/null +++ b/ci-scripts/helm/ci-env-controller/templates/clusterrole-controller.yaml @@ -0,0 +1,39 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ .Release.Name }}-controller + labels: + {{- include "ci-env-controller.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["namespaces"] + verbs: ["get", "list", "watch", "create", "delete"] + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["secrets", "serviceaccounts", "services", "pods"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["apps"] + resources: ["deployments", "replicasets"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["route.openshift.io"] + resources: ["routes"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["rbac.authorization.k8s.io"] + resources: ["clusterroles", "clusterrolebindings", "roles", "rolebindings"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["config.openshift.io"] + resources: ["clusterversions", "consoles", "infrastructures", "ingresses"] + verbs: ["get", "list", "watch"] + - apiGroups: ["console.openshift.io"] + resources: ["consoleplugins", "consoleclidownloads"] + verbs: ["get", "list", "watch"] + - apiGroups: ["storage.k8s.io"] + resources: ["storageclasses"] + verbs: ["get", "list", "watch"] + - apiGroups: ["apiextensions.k8s.io"] + resources: ["customresourcedefinitions"] + verbs: ["get", "list", "watch"] + - nonResourceURLs: ["/api", "/api/*", "/apis", "/apis/*", "/version"] + verbs: ["get"] diff --git a/ci-scripts/helm/ci-env-controller/templates/clusterrole-test-runner.yaml b/ci-scripts/helm/ci-env-controller/templates/clusterrole-test-runner.yaml new file mode 100644 index 00000000..c85dd5ac --- /dev/null +++ b/ci-scripts/helm/ci-env-controller/templates/clusterrole-test-runner.yaml @@ -0,0 +1,34 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ .Release.Name }}-test-runner + labels: + {{- include "ci-env-controller.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["namespaces"] + verbs: ["get", "list", "watch", "create", "delete"] + - apiGroups: [""] + resources: ["services", "configmaps", "secrets", "pods", "events"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["networking.k8s.io"] + resources: ["networkpolicies", "ingresses"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["k8s.cni.cncf.io"] + resources: ["network-attachment-definitions", "multi-networkpolicies"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["k8s.ovn.org"] + resources: ["userdefinednetworks", "clusteruserdefinednetworks"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["nmstate.io"] + resources: ["nodenetworkconfigurationpolicies"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["route.openshift.io"] + resources: ["routes"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["apps"] + resources: ["deployments", "replicasets"] + verbs: ["get", "list", "watch"] + - apiGroups: ["config.openshift.io"] + resources: ["clusterversions", "consoles", "infrastructures", "ingresses"] + verbs: ["get", "list", "watch"] diff --git a/ci-scripts/helm/ci-env-controller/templates/clusterrolebinding.yaml b/ci-scripts/helm/ci-env-controller/templates/clusterrolebinding.yaml new file mode 100644 index 00000000..bccee99e --- /dev/null +++ b/ci-scripts/helm/ci-env-controller/templates/clusterrolebinding.yaml @@ -0,0 +1,14 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ .Release.Name }}-controller + labels: + {{- include "ci-env-controller.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ .Release.Name }}-controller +subjects: + - kind: ServiceAccount + name: {{ .Release.Name }}-controller + namespace: {{ .Release.Namespace }} diff --git a/ci-scripts/helm/ci-env-controller/templates/configmap-script.yaml b/ci-scripts/helm/ci-env-controller/templates/configmap-script.yaml new file mode 100644 index 00000000..a7e41d39 --- /dev/null +++ b/ci-scripts/helm/ci-env-controller/templates/configmap-script.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-controller-script + labels: + {{- include "ci-env-controller.labels" . | nindent 4 }} +data: + ci-env-controller.sh: | +{{ .Files.Get "scripts/ci-env-controller.sh" | indent 4 }} diff --git a/ci-scripts/helm/ci-env-controller/templates/deployment.yaml b/ci-scripts/helm/ci-env-controller/templates/deployment.yaml new file mode 100644 index 00000000..c35b4441 --- /dev/null +++ b/ci-scripts/helm/ci-env-controller/templates/deployment.yaml @@ -0,0 +1,52 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-controller + labels: + {{- include "ci-env-controller.labels" . | nindent 4 }} +spec: + replicas: 1 + selector: + matchLabels: + app: {{ .Release.Name }}-controller + template: + metadata: + labels: + app: {{ .Release.Name }}-controller + spec: + serviceAccountName: {{ .Release.Name }}-controller + containers: + - name: controller + image: {{ .Values.controllerImage }} + command: ["/bin/bash", "/scripts/ci-env-controller.sh"] + env: + - name: RECONCILE_INTERVAL + value: {{ .Values.reconcileInterval | quote }} + - name: REAP_AFTER_MINUTES + value: {{ .Values.reapAfterMinutes | quote }} + - name: CI_ENV_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: CONSOLE_IMAGE_REGISTRY + value: {{ .Values.consoleImageRegistry | quote }} + {{- if .Values.runnerServiceAccount }} + - name: RUNNER_SERVICE_ACCOUNT + value: {{ .Values.runnerServiceAccount | quote }} + {{- end }} + volumeMounts: + - name: script + mountPath: /scripts + readOnly: true + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi + volumes: + - name: script + configMap: + name: {{ .Release.Name }}-controller-script + defaultMode: 0755 diff --git a/ci-scripts/helm/ci-env-controller/templates/role-trigger.yaml b/ci-scripts/helm/ci-env-controller/templates/role-trigger.yaml new file mode 100644 index 00000000..803b864f --- /dev/null +++ b/ci-scripts/helm/ci-env-controller/templates/role-trigger.yaml @@ -0,0 +1,10 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ .Release.Name }}-trigger + labels: + {{- include "ci-env-controller.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] diff --git a/ci-scripts/helm/ci-env-controller/templates/rolebinding-runner.yaml b/ci-scripts/helm/ci-env-controller/templates/rolebinding-runner.yaml new file mode 100644 index 00000000..2aa3048f --- /dev/null +++ b/ci-scripts/helm/ci-env-controller/templates/rolebinding-runner.yaml @@ -0,0 +1,16 @@ +{{- if .Values.runnerServiceAccount }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ .Release.Name }}-runner-trigger + labels: + {{- include "ci-env-controller.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ .Release.Name }}-trigger +subjects: + - kind: ServiceAccount + name: {{ .Values.runnerServiceAccount }} + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/ci-scripts/helm/ci-env-controller/templates/serviceaccount.yaml b/ci-scripts/helm/ci-env-controller/templates/serviceaccount.yaml new file mode 100644 index 00000000..399d0f80 --- /dev/null +++ b/ci-scripts/helm/ci-env-controller/templates/serviceaccount.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Release.Name }}-controller + labels: + {{- include "ci-env-controller.labels" . | nindent 4 }} diff --git a/ci-scripts/helm/ci-env-controller/values.yaml b/ci-scripts/helm/ci-env-controller/values.yaml new file mode 100644 index 00000000..2b2bb6b0 --- /dev/null +++ b/ci-scripts/helm/ci-env-controller/values.yaml @@ -0,0 +1,11 @@ +controllerImage: "registry.access.redhat.com/ubi9/ubi-minimal:latest" + +reconcileInterval: 15 + +reapAfterMinutes: 120 + +testStackChartPath: "/charts/ci-test-stack" + +runnerServiceAccount: "" + +consoleImageRegistry: "quay.io/openshift/origin-console" diff --git a/ci-scripts/helm/ci-test-stack/Chart.yaml b/ci-scripts/helm/ci-test-stack/Chart.yaml new file mode 100644 index 00000000..0b43367c --- /dev/null +++ b/ci-scripts/helm/ci-test-stack/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: ci-test-stack +description: Deploy networking-console-plugin + OpenShift console for E2E CI testing +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/ci-scripts/helm/ci-test-stack/templates/NOTES.txt b/ci-scripts/helm/ci-test-stack/templates/NOTES.txt new file mode 100644 index 00000000..f89b3dbf --- /dev/null +++ b/ci-scripts/helm/ci-test-stack/templates/NOTES.txt @@ -0,0 +1,10 @@ +CI Test Stack deployed: {{ .Release.Name }} + +Console: {{ include "ci-test-stack.consoleName" . }} +Plugin: {{ include "ci-test-stack.pluginName" . }} + +The console bridge URL for Cypress: + http://{{ include "ci-test-stack.consoleName" . }}.{{ .Release.Namespace }}.svc.cluster.local:9000 + +The external console route: + oc get route {{ include "ci-test-stack.consoleName" . }} -n {{ .Release.Namespace }} -o jsonpath='{.spec.host}' diff --git a/ci-scripts/helm/ci-test-stack/templates/_helpers.tpl b/ci-scripts/helm/ci-test-stack/templates/_helpers.tpl new file mode 100644 index 00000000..37ae0b18 --- /dev/null +++ b/ci-scripts/helm/ci-test-stack/templates/_helpers.tpl @@ -0,0 +1,29 @@ +{{/* +Common labels +*/}} +{{- define "ci-test-stack.labels" -}} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/instance: {{ .Release.Name }} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }} +{{- end }} + +{{/* +Console app name +*/}} +{{- define "ci-test-stack.consoleName" -}} +{{ .Release.Name }}-console +{{- end }} + +{{/* +Plugin app name +*/}} +{{- define "ci-test-stack.pluginName" -}} +{{ .Release.Name }}-plugin +{{- end }} + +{{/* +Console service account name +*/}} +{{- define "ci-test-stack.consoleSAName" -}} +{{ .Release.Name }}-console +{{- end }} diff --git a/ci-scripts/helm/ci-test-stack/templates/console-clusterrolebinding.yaml b/ci-scripts/helm/ci-test-stack/templates/console-clusterrolebinding.yaml new file mode 100644 index 00000000..5d24da91 --- /dev/null +++ b/ci-scripts/helm/ci-test-stack/templates/console-clusterrolebinding.yaml @@ -0,0 +1,14 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "ci-test-stack.consoleSAName" . }} + labels: + {{- include "ci-test-stack.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ .Release.Name }}-console +subjects: + - kind: ServiceAccount + name: {{ include "ci-test-stack.consoleSAName" . }} + namespace: {{ .Release.Namespace }} diff --git a/ci-scripts/helm/ci-test-stack/templates/console-configmap.yaml b/ci-scripts/helm/ci-test-stack/templates/console-configmap.yaml new file mode 100644 index 00000000..ee7e8e27 --- /dev/null +++ b/ci-scripts/helm/ci-test-stack/templates/console-configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "ci-test-stack.consoleName" . }}-config + labels: + {{- include "ci-test-stack.labels" . | nindent 4 }} +data: + plugin-proxy.json: | + {} diff --git a/ci-scripts/helm/ci-test-stack/templates/console-deployment.yaml b/ci-scripts/helm/ci-test-stack/templates/console-deployment.yaml new file mode 100644 index 00000000..f0ace808 --- /dev/null +++ b/ci-scripts/helm/ci-test-stack/templates/console-deployment.yaml @@ -0,0 +1,72 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "ci-test-stack.consoleName" . }} + labels: + app: {{ include "ci-test-stack.consoleName" . }} + {{- include "ci-test-stack.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app: {{ include "ci-test-stack.consoleName" . }} + template: + metadata: + labels: + app: {{ include "ci-test-stack.consoleName" . }} + spec: + serviceAccountName: {{ include "ci-test-stack.consoleSAName" . }} + containers: + - name: console + image: {{ .Values.consoleImage }} + ports: + - containerPort: 9000 + protocol: TCP + env: + - name: BRIDGE_USER_AUTH + value: "disabled" + - name: BRIDGE_K8S_MODE + value: "off-cluster" + - name: BRIDGE_K8S_AUTH + value: "bearer-token" + - name: BRIDGE_K8S_MODE_OFF_CLUSTER_SKIP_VERIFY_TLS + value: "true" + - name: BRIDGE_K8S_MODE_OFF_CLUSTER_ENDPOINT + value: {{ .Values.apiServerURL | quote }} + - name: BRIDGE_K8S_AUTH_BEARER_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "ci-test-stack.consoleSAName" . }}-token + key: token + - name: BRIDGE_USER_SETTINGS_LOCATION + value: "localstorage" + - name: BRIDGE_I18N_NAMESPACES + value: {{ .Values.i18nNamespaces | quote }} + - name: BRIDGE_PLUGINS + value: "networking-console-plugin=http://{{ include "ci-test-stack.pluginName" . }}:{{ .Values.pluginPort }}" + {{- if .Values.thanosURL }} + - name: BRIDGE_K8S_MODE_OFF_CLUSTER_THANOS + value: {{ .Values.thanosURL | quote }} + {{- end }} + {{- if .Values.alertmanagerURL }} + - name: BRIDGE_K8S_MODE_OFF_CLUSTER_ALERTMANAGER + value: {{ .Values.alertmanagerURL | quote }} + {{- end }} + {{- if .Values.pluginProxy }} + - name: BRIDGE_PLUGIN_PROXY + value: {{ .Values.pluginProxy | quote }} + {{- end }} + readinessProbe: + httpGet: + path: /health + port: 9000 + scheme: HTTP + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi diff --git a/ci-scripts/helm/ci-test-stack/templates/console-route.yaml b/ci-scripts/helm/ci-test-stack/templates/console-route.yaml new file mode 100644 index 00000000..7b8c93bb --- /dev/null +++ b/ci-scripts/helm/ci-test-stack/templates/console-route.yaml @@ -0,0 +1,17 @@ +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: {{ include "ci-test-stack.consoleName" . }} + labels: + {{- include "ci-test-stack.labels" . | nindent 4 }} +spec: + to: + kind: Service + name: {{ include "ci-test-stack.consoleName" . }} + weight: 100 + port: + targetPort: 9000 + tls: + termination: edge + insecureEdgeTerminationPolicy: Redirect + wildcardPolicy: None diff --git a/ci-scripts/helm/ci-test-stack/templates/console-sa.yaml b/ci-scripts/helm/ci-test-stack/templates/console-sa.yaml new file mode 100644 index 00000000..79d9ef52 --- /dev/null +++ b/ci-scripts/helm/ci-test-stack/templates/console-sa.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "ci-test-stack.consoleSAName" . }} + labels: + {{- include "ci-test-stack.labels" . | nindent 4 }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "ci-test-stack.consoleSAName" . }}-token + annotations: + kubernetes.io/service-account.name: {{ include "ci-test-stack.consoleSAName" . }} + labels: + {{- include "ci-test-stack.labels" . | nindent 4 }} +type: kubernetes.io/service-account-token diff --git a/ci-scripts/helm/ci-test-stack/templates/console-service.yaml b/ci-scripts/helm/ci-test-stack/templates/console-service.yaml new file mode 100644 index 00000000..24287a74 --- /dev/null +++ b/ci-scripts/helm/ci-test-stack/templates/console-service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "ci-test-stack.consoleName" . }} + labels: + {{- include "ci-test-stack.labels" . | nindent 4 }} +spec: + selector: + app: {{ include "ci-test-stack.consoleName" . }} + ports: + - port: 9000 + targetPort: 9000 + protocol: TCP diff --git a/ci-scripts/helm/ci-test-stack/templates/plugin-configmap-nginx.yaml b/ci-scripts/helm/ci-test-stack/templates/plugin-configmap-nginx.yaml new file mode 100644 index 00000000..2ffd6cda --- /dev/null +++ b/ci-scripts/helm/ci-test-stack/templates/plugin-configmap-nginx.yaml @@ -0,0 +1,39 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "ci-test-stack.pluginName" . }}-nginx + labels: + {{- include "ci-test-stack.labels" . | nindent 4 }} +data: + nginx.conf: | + pid /tmp/nginx.pid; + error_log /dev/stdout info; + + events { + worker_connections 1024; + } + + http { + access_log /dev/stdout; + include /etc/nginx/mime.types; + default_type application/octet-stream; + keepalive_timeout 65; + + add_header X-Content-Type-Options nosniff; + + server { + listen 8080 default_server; + root /opt/app-root/src; + + location = /plugin-manifest.json { + add_header Cache-Control 'no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0'; + add_header Pragma 'no-cache'; + add_header Expires '0'; + } + location = /plugin-entry.js { + add_header Cache-Control 'no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0'; + add_header Pragma 'no-cache'; + add_header Expires '0'; + } + } + } diff --git a/ci-scripts/helm/ci-test-stack/templates/plugin-deployment.yaml b/ci-scripts/helm/ci-test-stack/templates/plugin-deployment.yaml new file mode 100644 index 00000000..94e69085 --- /dev/null +++ b/ci-scripts/helm/ci-test-stack/templates/plugin-deployment.yaml @@ -0,0 +1,46 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "ci-test-stack.pluginName" . }} + labels: + app: {{ include "ci-test-stack.pluginName" . }} + {{- include "ci-test-stack.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app: {{ include "ci-test-stack.pluginName" . }} + template: + metadata: + labels: + app: {{ include "ci-test-stack.pluginName" . }} + spec: + containers: + - name: plugin + image: {{ .Values.pluginImage }} + ports: + - containerPort: 8080 + protocol: TCP + volumeMounts: + - name: nginx-config + mountPath: /etc/nginx/nginx.conf + subPath: nginx.conf + readOnly: true + readinessProbe: + httpGet: + path: /plugin-manifest.json + port: 8080 + scheme: HTTP + initialDelaySeconds: 3 + periodSeconds: 5 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi + volumes: + - name: nginx-config + configMap: + name: {{ include "ci-test-stack.pluginName" . }}-nginx diff --git a/ci-scripts/helm/ci-test-stack/templates/plugin-service.yaml b/ci-scripts/helm/ci-test-stack/templates/plugin-service.yaml new file mode 100644 index 00000000..32244567 --- /dev/null +++ b/ci-scripts/helm/ci-test-stack/templates/plugin-service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "ci-test-stack.pluginName" . }} + labels: + {{- include "ci-test-stack.labels" . | nindent 4 }} +spec: + selector: + app: {{ include "ci-test-stack.pluginName" . }} + ports: + - port: {{ .Values.pluginPort }} + targetPort: 8080 + protocol: TCP diff --git a/ci-scripts/helm/ci-test-stack/templates/runner-rolebinding.yaml b/ci-scripts/helm/ci-test-stack/templates/runner-rolebinding.yaml new file mode 100644 index 00000000..b5859b82 --- /dev/null +++ b/ci-scripts/helm/ci-test-stack/templates/runner-rolebinding.yaml @@ -0,0 +1,16 @@ +{{- if .Values.runnerServiceAccount }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ .Release.Name }}-runner + labels: + {{- include "ci-test-stack.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ .Release.Name }}-test-runner +subjects: + - kind: ServiceAccount + name: {{ .Values.runnerServiceAccount }} + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/ci-scripts/helm/ci-test-stack/values.yaml b/ci-scripts/helm/ci-test-stack/values.yaml new file mode 100644 index 00000000..c8135cd3 --- /dev/null +++ b/ci-scripts/helm/ci-test-stack/values.yaml @@ -0,0 +1,19 @@ +pluginImage: "" +pluginPort: 9001 + +consoleImage: "quay.io/openshift/origin-console:latest" +consolePort: 9000 + +apiServerURL: "" +bearerToken: "" + +i18nNamespaces: "plugin__networking-console-plugin" + +runnerServiceAccount: "" + +thanosURL: "" +alertmanagerURL: "" + +pluginProxy: "" + +replicaCount: 1 diff --git a/ci-scripts/nginx-9080.conf b/ci-scripts/nginx-9080.conf new file mode 100644 index 00000000..e6a377e1 --- /dev/null +++ b/ci-scripts/nginx-9080.conf @@ -0,0 +1,31 @@ +pid /tmp/nginx.pid; +error_log /dev/stdout info; + +events { + worker_connections 1024; +} + +http { + access_log /dev/stdout; + include /etc/nginx/mime.types; + default_type application/octet-stream; + keepalive_timeout 65; + + add_header X-Content-Type-Options nosniff; + + server { + listen 9080 default_server; + root /usr/share/nginx/html; + + location = /plugin-manifest.json { + add_header Cache-Control 'no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0'; + add_header Pragma 'no-cache'; + add_header Expires '0'; + } + location = /plugin-entry.js { + add_header Cache-Control 'no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0'; + add_header Pragma 'no-cache'; + add_header Expires '0'; + } + } +} diff --git a/ci-scripts/nginx-9443.conf b/ci-scripts/nginx-9443.conf new file mode 100644 index 00000000..49b94d63 --- /dev/null +++ b/ci-scripts/nginx-9443.conf @@ -0,0 +1,35 @@ +pid /tmp/nginx.pid; +error_log /dev/stdout info; + +events { + worker_connections 1024; +} + +http { + access_log /dev/stdout; + include /etc/nginx/mime.types; + default_type application/octet-stream; + keepalive_timeout 65; + + add_header X-Content-Type-Options nosniff; + + server { + listen 9443 ssl; + ssl_certificate /var/serving-cert/tls.crt; + ssl_certificate_key /var/serving-cert/tls.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + root /usr/share/nginx/html; + + location = /plugin-manifest.json { + add_header Cache-Control 'no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0'; + add_header Pragma 'no-cache'; + add_header Expires '0'; + } + location = /plugin-entry.js { + add_header Cache-Control 'no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0'; + add_header Pragma 'no-cache'; + add_header Expires '0'; + } + } +} diff --git a/ci-scripts/resolve-console-image.sh b/ci-scripts/resolve-console-image.sh new file mode 100755 index 00000000..32eaa08c --- /dev/null +++ b/ci-scripts/resolve-console-image.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Emit CONSOLE_IMAGE=... so the off-cluster console matches the cluster's OpenShift x.y version. +# Requires: oc logged in; OpenShift cluster with ClusterVersion. +set -euo pipefail + +CONSOLE_IMAGE_REGISTRY="${CONSOLE_IMAGE_REGISTRY:-quay.io/openshift/origin-console}" + +if [[ -n "${CONSOLE_IMAGE:-}" ]]; then + echo "CONSOLE_IMAGE already set (${CONSOLE_IMAGE}); skipping cluster version lookup." >&2 + echo "CONSOLE_IMAGE=${CONSOLE_IMAGE}" + exit 0 +fi + +VERSION="$(oc get clusterversion version -o jsonpath='{.status.desired.version}' 2>/dev/null || true)" +if [[ -z "${VERSION}" ]]; then + echo "::error::Could not read .status.desired.version from ClusterVersion 'version'. Is this an OpenShift cluster and is oc authenticated?" >&2 + exit 1 +fi + +IFS='.' read -r major minor _rest <<< "${VERSION}" +if [[ -z "${major:-}" || -z "${minor:-}" ]]; then + echo "::error::Could not parse OpenShift version '${VERSION}' as major.minor.*" >&2 + exit 1 +fi + +OCP_XY="${major}.${minor}" +CONSOLE_IMAGE="${CONSOLE_IMAGE_REGISTRY}:${OCP_XY}" + +echo "Cluster OpenShift version: ${VERSION} → console tag: ${OCP_XY}" >&2 +echo "CONSOLE_IMAGE=${CONSOLE_IMAGE}" + +if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + { + echo "### Off-cluster console image" + echo "" + echo "Cluster version: **${VERSION}**" + echo "Console image: \`${CONSOLE_IMAGE}\`" + } >> "${GITHUB_STEP_SUMMARY}" +fi diff --git a/ci-scripts/start-console.sh b/ci-scripts/start-console.sh new file mode 100755 index 00000000..88f8d707 --- /dev/null +++ b/ci-scripts/start-console.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# +# Start the "off cluster" console for CI testing. +# Networking-console-plugin does not need the kubevirt API proxy route, +# so this is simpler than the kubevirt-plugin equivalent. +# +set -euox pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# BRIDGE_* — off-cluster console +BRIDGE_USER_AUTH="disabled" +BRIDGE_K8S_MODE="off-cluster" +BRIDGE_K8S_AUTH="bearer-token" +BRIDGE_K8S_MODE_OFF_CLUSTER_SKIP_VERIFY_TLS="true" +BRIDGE_K8S_MODE_OFF_CLUSTER_ENDPOINT="$(oc whoami --show-server)" +BRIDGE_K8S_MODE_OFF_CLUSTER_THANOS="$(oc -n openshift-config-managed get configmap monitoring-shared-config -o jsonpath='{.data.thanosPublicURL}' 2>/dev/null || true)" +BRIDGE_K8S_MODE_OFF_CLUSTER_ALERTMANAGER="$(oc -n openshift-config-managed get configmap monitoring-shared-config -o jsonpath='{.data.alertmanagerPublicURL}' 2>/dev/null || true)" +BRIDGE_USER_SETTINGS_LOCATION="localstorage" +BRIDGE_I18N_NAMESPACES="plugin__networking-console-plugin" + +BRIDGE_K8S_AUTH_BEARER_TOKEN="$(oc whoami --show-token 2>/dev/null || true)" +if [[ -z "${BRIDGE_K8S_AUTH_BEARER_TOKEN}" ]]; then + echo "::error::Could not read bearer token (oc whoami --show-token)." + exit 1 +fi +echo "::add-mask::${BRIDGE_K8S_AUTH_BEARER_TOKEN}" + +BRIDGE_PLUGINS="networking-console-plugin=${PLUGIN_TRANSPORT}://host.docker.internal:${PLUGIN_PORT}" + +eval "$(bash "${SCRIPT_DIR}/resolve-console-image.sh")" || true +CONSOLE_IMAGE="${CONSOLE_IMAGE:-quay.io/openshift/origin-console:latest}" +CONSOLE_PORT=${CONSOLE_PORT:-9000} + +if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + { + echo "
Off-cluster console (CI)" + echo "" + echo "| Item | Value |" + echo "|------|-------|" + echo "| Console image | \`${CONSOLE_IMAGE}\` |" + echo "| Console URL | \`http://localhost:${CONSOLE_PORT}\` |" + echo "| API server | \`${BRIDGE_K8S_MODE_OFF_CLUSTER_ENDPOINT}\` |" + echo "| BRIDGE_PLUGINS | \`${BRIDGE_PLUGINS}\` |" + echo "" + echo "
" + } >> "${GITHUB_STEP_SUMMARY}" +fi + +echo "Starting console container..." +echo " API server: ${BRIDGE_K8S_MODE_OFF_CLUSTER_ENDPOINT}" +echo " Console image: ${CONSOLE_IMAGE}" + +DOCKER_RUN_EXTRA=() +if [[ "$(uname -s)" == "Linux" ]]; then + DOCKER_RUN_EXTRA+=(--add-host=host.docker.internal:host-gateway) +fi + +docker run -d --pull=always --rm "${DOCKER_RUN_EXTRA[@]}" \ + -p "${CONSOLE_PORT}:9000" \ + --name console \ + -e BRIDGE_USER_AUTH="${BRIDGE_USER_AUTH}" \ + -e BRIDGE_K8S_MODE="${BRIDGE_K8S_MODE}" \ + -e BRIDGE_K8S_AUTH="${BRIDGE_K8S_AUTH}" \ + -e BRIDGE_K8S_MODE_OFF_CLUSTER_SKIP_VERIFY_TLS="${BRIDGE_K8S_MODE_OFF_CLUSTER_SKIP_VERIFY_TLS}" \ + -e BRIDGE_K8S_MODE_OFF_CLUSTER_ENDPOINT="${BRIDGE_K8S_MODE_OFF_CLUSTER_ENDPOINT}" \ + -e BRIDGE_K8S_MODE_OFF_CLUSTER_THANOS="${BRIDGE_K8S_MODE_OFF_CLUSTER_THANOS}" \ + -e BRIDGE_K8S_MODE_OFF_CLUSTER_ALERTMANAGER="${BRIDGE_K8S_MODE_OFF_CLUSTER_ALERTMANAGER}" \ + -e BRIDGE_K8S_AUTH_BEARER_TOKEN="${BRIDGE_K8S_AUTH_BEARER_TOKEN}" \ + -e BRIDGE_USER_SETTINGS_LOCATION="${BRIDGE_USER_SETTINGS_LOCATION}" \ + -e BRIDGE_I18N_NAMESPACES="${BRIDGE_I18N_NAMESPACES}" \ + -e BRIDGE_PLUGINS="${BRIDGE_PLUGINS}" \ + "${CONSOLE_IMAGE}" diff --git a/ci-scripts/start-plugin-container.sh b/ci-scripts/start-plugin-container.sh new file mode 100755 index 00000000..0ffa5579 --- /dev/null +++ b/ci-scripts/start-plugin-container.sh @@ -0,0 +1,76 @@ +#!/bin/bash +set -euox pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +if [[ -n "${DOCKER_HOST:-}" ]] && command -v docker &>/dev/null; then + RUNTIME=$(command -v docker) +elif command -v podman &>/dev/null; then + RUNTIME=$(command -v podman) +else + RUNTIME=$(command -v docker) +fi + +VOL_SUFFIX=":ro" +[[ "${RUNTIME}" == *podman ]] && VOL_SUFFIX=":ro,z" + +if [[ -z "${PLUGIN_IMAGE:-}" ]]; then + PLUGIN_IMAGE="localhost/networking-console-plugin:local" + $RUNTIME build -t "${PLUGIN_IMAGE}" -f Dockerfile "${REPO_ROOT}" +fi + +PLUGIN_NAME=${PLUGIN_NAME:-networking-console-plugin-ci} +PLUGIN_PORT=${PLUGIN_PORT:-9001} +PLUGIN_TRANSPORT=${PLUGIN_TRANSPORT:-http} + +echo "Using PLUGIN_IMAGE: ${PLUGIN_IMAGE}" +echo "Using PLUGIN_NAME: ${PLUGIN_NAME}" +echo "Using PLUGIN_PORT: ${PLUGIN_PORT}" +echo "Using PLUGIN_TRANSPORT: ${PLUGIN_TRANSPORT}" + +if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + { + echo "
Networking Plugin Container" + echo "" + echo "| Item | Value |" + echo "|------|-------|" + echo "| Plugin image | \`${PLUGIN_IMAGE}\` |" + echo "| Plugin port | \`${PLUGIN_PORT}\` |" + echo "| Plugin transport | \`${PLUGIN_TRANSPORT}\` |" + echo "| Container name | \`${PLUGIN_NAME}\` |" + echo "| Container runtime | \`${RUNTIME}\` |" + echo "" + echo "
" + } >> "${GITHUB_STEP_SUMMARY}" +fi + +CERT_CONFIG="" +if [[ "${PLUGIN_TRANSPORT}" == "https" ]]; then + CERT_PARENT="${REPO_ROOT}/ci-scripts/generated" + mkdir -p "${CERT_PARENT}" || true + PLUGIN_CERT_DIR=$(mktemp -d "${CERT_PARENT}/.tmp-plugin-cert.XXXXXX") + openssl req -x509 -nodes -days 1 -newkey rsa:2048 \ + -keyout "${PLUGIN_CERT_DIR}/tls.key" \ + -out "${PLUGIN_CERT_DIR}/tls.crt" \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,DNS:host.docker.internal" + chmod a+rx "${PLUGIN_CERT_DIR}" + chmod a+r "${PLUGIN_CERT_DIR}/tls.crt" "${PLUGIN_CERT_DIR}/tls.key" + + CERT_CONFIG="-v ${PLUGIN_CERT_DIR}:/var/serving-cert${VOL_SUFFIX}" +fi + +CONTAINER_CONFIG=${SCRIPT_DIR}/nginx-9080.conf +CONTAINER_PORT=9080 +if [[ "${PLUGIN_TRANSPORT}" == "https" ]]; then + CONTAINER_CONFIG=${SCRIPT_DIR}/nginx-9443.conf + CONTAINER_PORT=9443 +fi + +$RUNTIME rm -f "${PLUGIN_NAME}" 2>/dev/null || true +$RUNTIME run -d \ + --name "${PLUGIN_NAME}" \ + -p "${PLUGIN_PORT}:${CONTAINER_PORT}" \ + -v "${CONTAINER_CONFIG}:/etc/nginx/nginx.conf${VOL_SUFFIX}" \ + ${CERT_CONFIG} \ + "${PLUGIN_IMAGE}" diff --git a/ci-scripts/test-cleanup.sh b/ci-scripts/test-cleanup.sh new file mode 100755 index 00000000..1e1ed077 --- /dev/null +++ b/ci-scripts/test-cleanup.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# +# Clean up test resources created during E2E runs. +# Uses TEST_NS and UDN_NS from the environment. +# +set -x +e + +TEST_NS="${TEST_NS:-cy-test-ns}" +UDN_NS="${UDN_NS:-udn-test-ns}" + +echo "Cleaning up test resources in ${TEST_NS}..." +oc delete --ignore-not-found=true -n "${TEST_NS}" net-attach-def network-bridge network-ovn network-localnet --wait=false +oc delete --ignore-not-found=true -n "${TEST_NS}" service example --wait=false +oc delete --ignore-not-found=true -n "${TEST_NS}" route auto-test-route --wait=false +oc delete --ignore-not-found=true -n "${TEST_NS}" ingress example --wait=false +oc delete --ignore-not-found=true -n "${TEST_NS}" networkpolicy auto-test-net-policy --wait=true --timeout=60s +oc delete --ignore-not-found=true -n "${TEST_NS}" multi-networkpolicy auto-test-multi-policy --wait=false + +echo "Cleaning up UDN resources..." +oc delete --ignore-not-found=true ClusterUserDefinedNetwork cluster-udn --wait=false +oc delete --ignore-not-found=true -n "${UDN_NS}" UserDefinedNetwork primary-udn --wait=false +oc delete nncp --all --ignore-not-found --wait=false + +echo "Deleting test namespaces..." +oc delete namespace "${TEST_NS}" --ignore-not-found --wait=false +oc delete namespace "${UDN_NS}" --ignore-not-found --wait=false + +echo "Cleanup done." diff --git a/cleanup.sh b/cleanup.sh new file mode 100755 index 00000000..31460387 --- /dev/null +++ b/cleanup.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +# Cleanup test resources from previous runs +# Sources .env for namespace names + +source .env 2>/dev/null || true +TEST_NS="${TEST_NS:-cy-test-ns}" +UDN_NS="${UDN_NS:-udn-test-ns}" + +cleanup () { + echo "Cleaning up test resources..." + oc delete --ignore-not-found=true -n ${TEST_NS} net-attach-def network-bridge network-ovn network-localnet --wait=false + oc delete --ignore-not-found=true -n ${TEST_NS} service example --wait=false + oc delete --ignore-not-found=true -n ${TEST_NS} route auto-test-route --wait=false + oc delete --ignore-not-found=true -n ${TEST_NS} ingress example --wait=false + oc delete --ignore-not-found=true -n ${TEST_NS} networkpolicy auto-test-net-policy --wait=true --timeout=60s + oc delete --ignore-not-found=true -n ${TEST_NS} multi-networkpolicy auto-test-multi-policy --wait=false + oc delete --ignore-not-found=true ClusterUserDefinedNetwork cluster-udn --wait=false + oc delete --ignore-not-found=true -n ${UDN_NS} UserDefinedNetwork primary-udn --wait=false + oc delete nncp --all --ignore-not-found --wait=false + echo "Cleanup done." +} diff --git a/integration-tests/support/login.ts b/integration-tests/support/login.ts index 0c590742..0887092c 100644 --- a/integration-tests/support/login.ts +++ b/integration-tests/support/login.ts @@ -8,7 +8,7 @@ declare global { } const KUBEADMIN_USERNAME = 'kubeadmin'; -const loginUsername = Cypress.env('BRIDGE_KUBEADMIN_PASSWORD') ? 'user-dropdown' : 'username'; +const loginUsername = cy.env('BRIDGE_KUBEADMIN_PASSWORD') ? 'user-dropdown' : 'username'; // This will add 'cy.login(...)' // ex: cy.login('my-user', 'my-password') @@ -24,7 +24,7 @@ Cypress.Commands.add('login', (username: string, password: string) => { cy.clearCookie('openshift-session-token'); cy.get('#inputUsername').type(username || KUBEADMIN_USERNAME); - cy.get('#inputPassword').type(password || Cypress.env('BRIDGE_KUBEADMIN_PASSWORD')); + cy.get('#inputPassword').type(cy.env('BRIDGE_KUBEADMIN_PASSWORD') || password); cy.get('button[type=submit]').click(); cy.get(`[data-test="${loginUsername}"]`).should('be.visible'); diff --git a/integration-tests/tests/example-page.cy.ts b/integration-tests/tests/example-page.cy.ts index 922011c7..9e1ef5ac 100644 --- a/integration-tests/tests/example-page.cy.ts +++ b/integration-tests/tests/example-page.cy.ts @@ -1,7 +1,7 @@ import { checkErrors } from '../support'; const PLUGIN_TEMPLATE_NAME = 'networking-console-plugin'; -const PLUGIN_TEMPLATE_PULL_SPEC = Cypress.env('PLUGIN_TEMPLATE_PULL_SPEC'); +const PLUGIN_TEMPLATE_PULL_SPEC = Cypress.expose('PLUGIN_TEMPLATE_PULL_SPEC'); export const isLocalDevEnvironment = Cypress.config('baseUrl').includes('localhost'); const installHelmChart = (path: string) => { diff --git a/package-lock.json b/package-lock.json index 6ef25bc7..d80cf074 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "css-loader": "^6.7.1", "cypress": "^15.0.0", "cypress-multi-reporters": "^2.0.0", + "dotenv": "^17.4.2", "esbuild-loader": "^4.4.2", "eslint": "^8.57.1", "eslint-config-prettier": "^8.10.0", @@ -7638,6 +7639,19 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index 3632871c..ef189fdc 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,14 @@ "start-console": "./start-console.sh", "i18n": "./i18n-scripts/build-i18n.sh && node ./i18n-scripts/set-english-defaults.js", "ts-node": "ts-node -O '{\"module\":\"commonjs\"}'", - "lint": "eslint ./src ./integration-tests", - "lint-fix": "eslint ./src ./integration-tests --fix", - "test-cypress": "cd integration-tests s && cypress open --env openshift=true", + "lint": "eslint ./src ./integration-tests ./cypress", + "lint-fix": "eslint ./src ./integration-tests ./cypress --fix", + "test-cypress": "cd integration-tests && cypress open --env openshift=true", "test-cypress-headless": "cd integration-tests && node --max-old-space-size=4096 ../node_modules/.bin/cypress run --env openshift=true --browser ${BRIDGE_E2E_BROWSER_NAME:=electron}", - "cypress-merge": "mochawesome-merge ./integration-tests/screenshots/cypress_report*.json > ./integration-tests/screenshots/cypress.json", - "cypress-generate": "marge -o ./integration-tests/screenshots/ -f cypress-report -t 'OpenShift Console Plugin Template Cypress Test Results' -p 'OpenShift Cypress Plugin Template Test Results' --showPassed false --assetsDir ./integration-tests/screenshots/cypress/assets ./integration-tests/screenshots/cypress.json", + "test-e2e": "cd cypress && node --max-old-space-size=4096 ../node_modules/.bin/cypress run --env openshift=true --browser ${BRIDGE_E2E_BROWSER_NAME:=electron}", + "test-e2e-open": "cd cypress && cypress open --env openshift=true", + "cypress-merge": "mochawesome-merge ./cypress/gui-test-screenshots/cypress_report*.json > ./cypress/gui-test-screenshots/cypress.json", + "cypress-generate": "marge -o ./cypress/gui-test-screenshots/ -f cypress-report -t 'Networking Console Plugin Test Results' -p 'Networking Plugin Test Results' --showPassed false --assetsDir ./cypress/gui-test-screenshots/assets ./cypress/gui-test-screenshots/cypress.json", "cypress-postreport": "npm run cypress-merge && npm run cypress-generate", "memsource-upload": "./i18n-scripts/memsource-upload.sh", "memsource-download": "./i18n-scripts/memsource-download.sh", @@ -58,6 +60,7 @@ "css-loader": "^6.7.1", "cypress": "^15.0.0", "cypress-multi-reporters": "^2.0.0", + "dotenv": "^17.4.2", "esbuild-loader": "^4.4.2", "eslint": "^8.57.1", "eslint-config-prettier": "^8.10.0", @@ -79,8 +82,8 @@ "pluralize": "^8.0.0", "prettier": "^3.2.5", "react": "18.3.1", - "react-dom": "18.3.1", "react-copy-to-clipboard": "^5.1.0", + "react-dom": "18.3.1", "react-i18next": "~16.5.8", "react-router": "~7.13.1", "resolve-url-loader": "^5.0.0", diff --git a/research-flakiness.sh b/research-flakiness.sh new file mode 100755 index 00000000..459d6ba1 --- /dev/null +++ b/research-flakiness.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash + +set -x +set +e + +PROBLEM_DIR="problem" +TOTAL_RUNS=10 +PASS_COUNT=0 +FAIL_COUNT=0 + +mkdir -p "${PROBLEM_DIR}" + +for i in $(seq -w 1 ${TOTAL_RUNS}); do + echo "========================================" + echo " RUN ${i} of ${TOTAL_RUNS}" + echo "========================================" + + # Clean previous test artifacts + rm -rf cypress/gui-test-screenshots/* + + # Run the test suite + ./test-cypress.sh + exit_code=$? + + if [ ${exit_code} -ne 0 ]; then + FAIL_COUNT=$((FAIL_COUNT + 1)) + RUN_DIR="${PROBLEM_DIR}/run${i}" + mkdir -p "${RUN_DIR}" + + # Copy screenshots and videos + if [ -d "cypress/gui-test-screenshots/screenshots" ]; then + cp -r cypress/gui-test-screenshots/screenshots "${RUN_DIR}/" + fi + if [ -d "cypress/gui-test-screenshots/videos" ]; then + cp -r cypress/gui-test-screenshots/videos "${RUN_DIR}/" + fi + if [ -f "cypress/gui-test-screenshots/build.log" ]; then + cp cypress/gui-test-screenshots/build.log "${RUN_DIR}/" + fi + + # Extract failure info into analysis.md + cat > "${RUN_DIR}/analysis.md" </dev/null || echo "Could not extract test names") + +## Error messages + +$(grep -A5 "^\s+\d\) " cypress/gui-test-screenshots/build.log 2>/dev/null | head -60 || echo "Could not extract errors") + +## Observations + +- The NAD creation form uses react-hook-form with auto-generated names +- The naive approach (clear + type without waits) races with React re-renders +- The form may overwrite user input after async state updates + +ANALYSIS + + echo " -> FAILED (evidence saved to ${RUN_DIR}/)" + else + PASS_COUNT=$((PASS_COUNT + 1)) + echo " -> PASSED" + fi +done + +echo "" +echo "========================================" +echo " RESULTS: ${PASS_COUNT} passed, ${FAIL_COUNT} failed out of ${TOTAL_RUNS} runs" +echo "========================================" + +# Write summary +cat > "${PROBLEM_DIR}/summary.md" </dev/null || true +TEST_NS="${TEST_NS:-cy-test-ns}" +UDN_NS="${UDN_NS:-udn-test-ns}" +UDN_LABEL="k8s.ovn.org/primary-user-defined-network" + +setup () { + echo "Setting up test namespaces..." + oc get namespace ${TEST_NS} 2>/dev/null || oc create namespace ${TEST_NS} + + # UDN namespace must have the label at creation time (admission policy prevents adding later) + if oc get namespace ${UDN_NS} 2>/dev/null; then + if ! oc get namespace ${UDN_NS} -o jsonpath='{.metadata.labels}' | grep -q "${UDN_LABEL}"; then + echo "UDN namespace exists but missing label, recreating..." + oc delete namespace ${UDN_NS} --wait=true --timeout=120s + oc wait --for=delete namespace/${UDN_NS} --timeout=120s 2>/dev/null || true + oc create -f - < ghWorkflow + prow --> prowScript + ghWorkflow --> cypressConfig + prowScript --> cypressConfig +``` + +## Migration Strategy + +The `release-4.21` Cypress tests are the primary source — copy them and adapt (fix imports, strip VM tests). Cross-reference with `main` Playwright specs only to check for newer test logic that may have been added after the Cypress versions were dropped. + +### What to copy from kubevirt-ui `release-4.21` + +| Source | Copy to networking-console-plugin | +|---|---| +| `cypress/views/nad.ts` | `cypress/views/nad.ts` | +| `cypress/views/udn.ts` | `cypress/views/udn.ts` | +| `cypress/views/actions.ts` (partial) | `cypress/views/actions.ts` | +| `cypress/views/selector-common.ts` (partial) | `cypress/views/selector-common.ts` | +| `cypress/views/selector-template.ts` (partial) | `cypress/views/selector-common.ts` | +| `cypress/support/nav.ts` (networking parts) | `cypress/support/nav.ts` | +| `cypress/support/selectors.ts` | `cypress/support/selectors.ts` | +| `cypress/support/commands.ts` (partial) | `cypress/support/commands.ts` | +| `cypress/utils/const/nad.ts` | `cypress/utils/const/nad.ts` | +| `cypress/utils/const/index.ts` (partial) | `cypress/utils/const/index.ts` | +| `cypress/utils/types/nad.ts` | `cypress/utils/types/nad.ts` | + +### What needs adaptation + +- **Remove VM-dependent code**: all `vm.*` calls, `cy.deleteVM()`, `VirtualMachineData` imports, VM status assertions +- **Remove kubevirt-specific imports**: `TEMPLATE`, `vm-flow`, `tab`, `vm` view modules +- **Remove kubevirt-perspective switching**: `cy.beforeSpec()` switches to Virtualization perspective — networking tests should stay in Administrator perspective +- **Fix relative import paths**: `../../../views/` -> `../views/` (flatter structure) +- **Keep only networking custom commands**: `cy.visitNAD()`, `cy.visitUDN()`, `cy.switchProject()`, `cy.deleteResource()` + +## Implementation Steps + +### 1. Cypress support infrastructure (`cypress/support/`) + +- **`support/selectors.ts`** — `cy.byTestID()`, `cy.byButtonText()`, `cy.checkTitle()`, `cy.checkSubTitle()`, `cy.switchProject()`, `cy.clickNavLink()`, `cy.clickBtn()` +- **`support/commands.ts`** — `cy.deleteResource(kind, name, ns)`, `cy.beforeSpec()` +- **`support/nav.ts`** — `cy.visitNAD()`, `cy.visitUDN()`, `cy.visitService()` + +### 2. Views and constants + +- **`views/nad.ts`** — `createNAD(nad: NadData)`, `deleteNAD(name: string)` + selectors +- **`views/udn.ts`** — `createUDN(project, subnet)`, `createClusterUDN(name, subnet, nsSelector)`, `deleteClusterUDN(name)` +- **`views/net-policies.ts`** — `denyTraffic()`, form radio/name fill helpers +- **`views/actions.ts`** — `checkActionMenu(kind)`, `getRow(name, within)` +- **`views/selector-common.ts`** — `row`, `brCrumbItem`, `itemFilter`, `createBtn`, `confirmBtn` +- **`utils/const/index.ts`** — `TEST_NS`, `UDN_NS`, `adminOnlyDescribe`, test names +- **`utils/const/nad.ts`** — `NAD_BRIDGE`, `NAD_OVN`, `NAD_LOCALNET` data objects +- **`utils/types/nad.ts`** — `NadData` interface + +### 3. Copy and adapt spec files + +| New file | Copies from (release-4.21) | Adaptations | +|---|---|---| +| `tests/udn.cy.ts` | `cypress/tests/tier2/networking/udn.cy.ts` | Remove VM creation tests + Passt section; keep create UDN, create CUDN, delete CUDN. UDN-enabled namespace created via shell (`oc`/`kubectl`) in `before()` hook, not via UI | +| `tests/nad-bridge.cy.ts` | `cypress/tests/tier2/networking/nad-bridge.cy.ts` | Remove VM creation/IP verification tests; keep `createNAD(NAD_BRIDGE)` | +| `tests/nad-localnet.cy.ts` | `cypress/tests/tier2/networking/nad-localnet.cy.ts` | Remove VM test; keep create + delete NAD | +| `tests/nad-ovn.cy.ts` | `cypress/tests/tier2/networking/nad-ovn.cy.ts` | Remove VM tests; keep `createNAD(NAD_OVN)` | +| `tests/net-policies.cy.ts` | `cypress/tests/tier2/networking/net-policies.cy.ts` | Keep as-is (MultiNetworkPolicy already xit) | +| `tests/services.cy.ts` | `cypress/tests/tier2/networking/services.cy.ts` | Adapt imports only | +| `tests/routes.cy.ts` | `cypress/tests/tier2/networking/routes.cy.ts` | Adapt imports only | +| `tests/ingresses.cy.ts` | `cypress/tests/tier2/networking/ingresses.cy.ts` | Adapt imports only | + +**Total: ~14 test cases** across 8 files + +### 4. GitHub Actions hot-cluster CI + +Create `.github/workflows/e2e.yml` following kubevirt-plugin PR #3713: +- Self-hosted runner on persistent OpenShift cluster +- Secrets: `CONSOLE_URL`, `KUBEADMIN_PASSWORD` +- Runs `npm run test-cypress-headless` +- Uploads JUnit + screenshots as artifacts +- ~5 min feedback loop + +### 5. Prow CI (existing) + +[`test-prow-e2e.sh`](../test-prow-e2e.sh) already runs `npm run test-cypress-headless`. Once specs are in place, it will run them with no script changes. A Prow job definition may need to be added/updated in `openshift/release`. + +### 6. Update kubevirt-ui (post-migration) + +The VM-dependent tests remaining in kubevirt-ui will break because they relied on UDN/CUDN/NAD creation from preceding tests in the same file. These need API-based setup: +- `oc apply -f` or `cy.exec('oc create ...')` to create UDN/CUDN/NAD resources before VM tests run +- This is tracked as part of the kubevirt-ui side of CNV-87983 + +### 7. Update Jira CNV-87983 + +- Update epic description with PR links +- Progress subtask CNV-87989 ("automated tests") +- Document which tests were migrated, which stay (with API setup), and which go to nmstate-console-plugin + +## File Structure (final) + +``` +cypress/ + cypress.config.js + tsconfig.json + plugins/ + index.ts + support/ + index.ts + login.ts + commands.ts + nav.ts + selectors.ts + views/ + nad.ts + udn.ts + actions.ts + selector-common.ts + utils/ + types/ + nad.ts + const/ + index.ts + nad.ts + scale.ts + tests/ + nad-bridge.cy.ts + nad-localnet.cy.ts + nad-ovn.cy.ts + udn.cy.ts + net-policies.cy.ts + services.cy.ts + routes.cy.ts + ingresses.cy.ts +.github/ + workflows/ + e2e.yml (hot-cluster CI) +test-prow-e2e.sh (updated screenshots path) +``` + +## Key Design Decisions + +- **Copy from Cypress (release-4.21)** — the primary source; tests did not differ much between release-4.21 and main +- **Cross-reference Playwright (main)** — check for any newer logic added after Cypress was dropped +- **Strip VM-dependent tests, don't delete them** — they stay in kubevirt-ui and will need API-based setup (create UDN/CUDN/NAD via `oc apply`) as a precondition instead of relying on prior UI tests +- **adminOnlyDescribe** — NAD/UDN tests require admin privileges; guard with `Cypress.expose('NON_PRIV')` check +- **beforeSpec without Virtualization perspective** — kubevirt-ui's `cy.beforeSpec()` switches to Virtualization perspective; our version should remain in Administrator perspective since networking resources are accessed from there +- **UDN namespace via shell** — UDN-enabled namespace creation uses `cy.exec('oc ...')` (shell/API), matching the kubevirt-ui approach where it's done in global setup via `setupTestNamespace(namespace, { 'k8s.ovn.org/primary-user-defined-network': '' })`. In Cypress this translates to `cy.exec('oc create namespace ...')` + label application diff --git a/ui-tests-cy/cypress.config.js b/ui-tests-cy/cypress.config.js new file mode 100644 index 00000000..f2dcc5a2 --- /dev/null +++ b/ui-tests-cy/cypress.config.js @@ -0,0 +1,31 @@ +const { defineConfig } = require('cypress'); + +module.exports = defineConfig({ + defaultCommandTimeout: 60000, + e2e: { + setupNodeEvents(on, config) { + return require('./plugins/index.ts')(on, config); + }, + specPattern: 'tests/**/*.cy.ts', + supportFile: 'support/index.ts', + testIsolation: false, + }, + fixturesFolder: false, + pageLoadTimeout: 120000, + reporter: '../node_modules/cypress-multi-reporters', + reporterOptions: { + configFile: 'reporter-config.json', + }, + retries: { + openMode: 0, + runMode: 0, + }, + screenshotOnRunFailure: true, + screenshotsFolder: './gui-test-screenshots/screenshots/', + trashAssetsBeforeRuns: true, + video: true, + videosFolder: './gui-test-screenshots/videos/', + viewportHeight: 1080, + viewportWidth: 1920, + watchForFileChanges: false, +}); diff --git a/ui-tests-cy/plugins/index.ts b/ui-tests-cy/plugins/index.ts new file mode 100644 index 00000000..9d7dc67f --- /dev/null +++ b/ui-tests-cy/plugins/index.ts @@ -0,0 +1,31 @@ +const wp = require('@cypress/webpack-preprocessor'); +const path = require('path'); +const dotenv = require('dotenv'); + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +module.exports = (on, config) => { + const options = { + webpackOptions: { + module: { + rules: [ + { + exclude: /node_modules/, + loader: 'esbuild-loader', + test: /\.ts$/, + }, + ], + }, + resolve: { + extensions: ['.ts', '.tsx', '.js'], + }, + }, + }; + on('file:preprocessor', wp(options)); + config.baseUrl = `${process.env.BRIDGE_BASE_ADDRESS || 'http://localhost:9000/'}`; + config.env.BRIDGE_KUBEADMIN_PASSWORD = process.env.BRIDGE_KUBEADMIN_PASSWORD; + config.env.TEST_NS = process.env.TEST_NS; + config.env.UDN_NS = process.env.UDN_NS; + config.env.HIDE_XHR = process.env.HIDE_XHR; + return config; +}; diff --git a/ui-tests-cy/reporter-config.json b/ui-tests-cy/reporter-config.json new file mode 100644 index 00000000..83ca6c5d --- /dev/null +++ b/ui-tests-cy/reporter-config.json @@ -0,0 +1,14 @@ +{ + "reporterEnabled": "mocha-junit-reporter, mochawesome", + "mochaJunitReporterReporterOptions": { + "mochaFile": "./gui-test-screenshots/junit_cypress-[hash].xml", + "toConsole": false + }, + "mochawesomeReporterOptions": { + "reportDir": "./gui-test-screenshots/", + "reportFilename": "cypress_report", + "overwrite": false, + "html": true, + "json": true + } +} \ No newline at end of file diff --git a/ui-tests-cy/support/commands.ts b/ui-tests-cy/support/commands.ts new file mode 100644 index 00000000..c2a7ebed --- /dev/null +++ b/ui-tests-cy/support/commands.ts @@ -0,0 +1,48 @@ +import { MINUTE, SECOND } from '../utils/const/base'; + +declare global { + namespace Cypress { + interface Chainable { + deleteResource(kind: string, name: string, namespace?: string): void; + switchProject(projectName: string): void; + } + } +} + +Cypress.Commands.add('deleteResource', (kind: string, name: string, namespace?: string) => { + if (!namespace) { + cy.exec(`oc delete --ignore-not-found=true ${kind} ${name} --wait=true --timeout=300s`, { + failOnNonZeroExit: false, + timeout: 5 * MINUTE, + }); + return; + } + cy.exec( + `oc delete --ignore-not-found=true -n ${namespace} ${kind} ${name} --wait=true --timeout=300s`, + { failOnNonZeroExit: false, timeout: 5 * MINUTE }, + ); +}); + +Cypress.Commands.add('switchProject', (projectName: string) => { + cy.byLegacyTestID('namespace-bar-dropdown').contains('Project:').click(); + if (projectName === 'All Projects') { + cy.contains('All Projects').click(); + return; + } + cy.get('body').then(($body) => { + if ($body.find('[data-test="showSystemSwitch"]').length) { + cy.byTestID('showSystemSwitch').check(); + cy.wait(3 * SECOND); + } + }); + cy.get( + 'input[data-test="dropdown-text-filter"], input[placeholder*="name"], input[aria-label*="filter"]', + { timeout: 10000 }, + ) + .first() + .clear() + .type(projectName); + cy.contains('[data-test="dropdown-menu-item-link"], [role="option"], button', projectName, { + timeout: 10000, + }).click(); +}); diff --git a/ui-tests-cy/support/index.ts b/ui-tests-cy/support/index.ts new file mode 100644 index 00000000..588da326 --- /dev/null +++ b/ui-tests-cy/support/index.ts @@ -0,0 +1,18 @@ +import './login'; +import './selectors'; +import './commands'; +import './nav'; + +Cypress.on('uncaught:exception', () => { + return false; +}); + +if (Cypress.env('HIDE_XHR')) { + const origLog = Cypress.log; + Cypress.log = function (opts, ...other) { + if (opts.displayName === 'fetch' || opts.displayName === 'xhr') { + return; + } + return origLog(opts, ...other); + }; +} diff --git a/ui-tests-cy/support/login.ts b/ui-tests-cy/support/login.ts new file mode 100644 index 00000000..484828d5 --- /dev/null +++ b/ui-tests-cy/support/login.ts @@ -0,0 +1,60 @@ +import { MINUTE } from '../utils/const/base'; + +const KUBEADMIN_USERNAME = 'kubeadmin'; +const KUBEADMIN_IDP = 'kube:admin'; +const TOUR_DISMISS = '[data-test="tour-step-footer-secondary"]'; + +declare global { + namespace Cypress { + interface Chainable { + login(providerName?: string, username?: string, password?: string): Chainable; + logout(): void; + } + } +} + +Cypress.Commands.add('login', (provider: string, username: string, password: string) => { + const usr = username || KUBEADMIN_USERNAME; + const pwd = password || Cypress.env('BRIDGE_KUBEADMIN_PASSWORD'); + const idp = provider || KUBEADMIN_IDP; + + cy.visit(''); + + cy.origin( + Cypress.config('baseUrl').replace('console-openshift-console', 'oauth-openshift'), + { args: { idp, pwd, usr } }, + ({ idp: originIdp, pwd: originPwd, usr: originUsr }) => { + // Wait for either the login form or IDP selection page + cy.get('body', { timeout: 180000 }).should('be.visible'); + cy.get('body').then(($body) => { + if ($body.find('#inputUsername').length === 0) { + // IDP selection page — click the matching provider + if ($body.text().includes(originIdp)) { + cy.contains('a', originIdp).click(); + } else if ($body.text().includes('kubeadmin')) { + cy.contains('a', 'kubeadmin').click(); + } else { + cy.get('a').first().click(); + } + } + }); + cy.get('#inputUsername', { timeout: 180000 }).should('be.visible'); + cy.get('#inputUsername').type(originUsr); + cy.get('#inputPassword').type(originPwd); + cy.get('button[type=submit]').click(); + }, + ); + + cy.url({ timeout: 2 * MINUTE }).should('include', 'console-openshift-console'); + cy.get('body').then(($body) => { + if ($body.find(TOUR_DISMISS).length) { + cy.get(TOUR_DISMISS).click(); + } + }); +}); + +Cypress.Commands.add('logout', () => { + cy.get('[data-test="user-dropdown"]').click(); + cy.get('[data-test="log-out"]').should('be.visible'); + cy.get('[data-test="log-out"]').click({ force: true }); +}); diff --git a/ui-tests-cy/support/nav.ts b/ui-tests-cy/support/nav.ts new file mode 100644 index 00000000..bde8f12d --- /dev/null +++ b/ui-tests-cy/support/nav.ts @@ -0,0 +1,28 @@ +import { MINUTE } from '../utils/const/base'; + +declare global { + namespace Cypress { + interface Chainable { + visitNAD(): void; + visitService(): void; + visitUDN(): void; + } + } +} + +Cypress.Commands.add('visitNAD', () => { + const ns = Cypress.env('TEST_NS') || 'cy-test-ns'; + cy.visit(`/k8s/ns/${ns}/k8s.cni.cncf.io~v1~NetworkAttachmentDefinition`); + cy.checkTitle('NetworkAttachmentDefinitions', MINUTE); +}); + +Cypress.Commands.add('visitUDN', () => { + cy.visit('/k8s/all-namespaces/k8s.ovn.org~v1~UserDefinedNetwork'); + cy.checkTitle('UserDefinedNetworks', MINUTE); + cy.byButtonText('Create').should('be.visible'); +}); + +Cypress.Commands.add('visitService', () => { + cy.visit('/k8s/all-namespaces/services'); + cy.checkTitle('Services', MINUTE); +}); diff --git a/ui-tests-cy/support/selectors.ts b/ui-tests-cy/support/selectors.ts new file mode 100644 index 00000000..935fb8ec --- /dev/null +++ b/ui-tests-cy/support/selectors.ts @@ -0,0 +1,81 @@ +import Loggable = Cypress.Loggable; +import Timeoutable = Cypress.Timeoutable; +import Withinable = Cypress.Withinable; +import Shadow = Cypress.Shadow; +import { MINUTE, SECOND } from '../utils/const/base'; + +export {}; +declare global { + namespace Cypress { + interface Chainable { + byButtonText(selector: string): Chainable; + byLegacyTestID(selector: string): Chainable; + byTestActionID(selector: string): Chainable; + byTestID( + selector: string, + options?: Partial, + ): Chainable; + byTestRows(selector: string): Chainable; + checkSubTitle(title: string, timeout?: number): void; + checkTitle(title: string, timeout?: number): void; + clickBtn(btnTxt: string): void; + clickNavLink(path: [string, string?]): Chainable; + clickNextBtn(): void; + clickSaveBtn(): void; + } + } +} + +Cypress.Commands.add( + 'byTestID', + (selector: string, options?: Partial) => { + cy.get(`[data-test="${selector}"]`, options); + }, +); + +Cypress.Commands.add('byLegacyTestID', (selector: string) => + cy.get(`[data-test-id="${selector}"]`, { timeout: MINUTE * 3 }), +); + +Cypress.Commands.add('byTestRows', (selector: string) => + cy.get(`[data-test-rows="${selector}"]`, { timeout: MINUTE }), +); + +Cypress.Commands.add('byTestActionID', (selector: string) => + cy.get(`[data-test-action="${selector}"]:not(.pf-m-disabled)`), +); + +Cypress.Commands.add('clickNavLink', (path: [string, string?]) => { + cy.byTestID('nav', { timeout: MINUTE }) + .contains(path[0], { timeout: SECOND * 10 }) + .should(($el) => { + if ($el.attr('aria-expanded') == 'false') { + $el.click(); + } + }); + if (path.length > 1) { + cy.get('#page-sidebar').contains(path[1]).click(); + } +}); + +Cypress.Commands.add('byButtonText', (selector: string) => cy.contains('button', `${selector}`)); + +Cypress.Commands.add('clickSaveBtn', () => { + cy.get('button[data-test="save-button"]').click({ force: true }); +}); + +Cypress.Commands.add('checkTitle', (title: string, timeout?: number) => { + const t_o = timeout ? timeout : MINUTE * 3; + cy.contains('h1', title, { timeout: t_o }).should('exist'); +}); + +Cypress.Commands.add('checkSubTitle', (subTitle: string, timeout?: number) => { + const t_o = timeout ? timeout : MINUTE * 3; + cy.contains('h2', subTitle, { timeout: t_o }).should('exist'); +}); + +Cypress.Commands.add('clickNextBtn', () => cy.contains('button[type="submit"]', 'Next').click()); + +Cypress.Commands.add('clickBtn', (btnTxt?: string) => { + return cy.contains('button[type="submit"]', btnTxt).click(); +}); diff --git a/ui-tests-cy/tests/all.cy.ts b/ui-tests-cy/tests/all.cy.ts new file mode 100644 index 00000000..a78e00c0 --- /dev/null +++ b/ui-tests-cy/tests/all.cy.ts @@ -0,0 +1,10 @@ +import './setup/login.cy.ts'; +import './setup/visit-pages.cy.ts'; +import './networking/udn.cy.ts'; +import './networking/nad-bridge.cy.ts'; +import './networking/nad-localnet.cy.ts'; +import './networking/nad-ovn.cy.ts'; +import './networking/services.cy.ts'; +import './networking/routes.cy.ts'; +import './networking/ingresses.cy.ts'; +import './networking/net-policies.cy.ts'; diff --git a/ui-tests-cy/tests/networking/ingresses.cy.ts b/ui-tests-cy/tests/networking/ingresses.cy.ts new file mode 100644 index 00000000..c6f820ba --- /dev/null +++ b/ui-tests-cy/tests/networking/ingresses.cy.ts @@ -0,0 +1,20 @@ +import { EXAMPLE, MINUTE, TEST_NS } from '../../utils/const/base'; +import { checkActionMenu } from '../../views/actions'; +import { createBtn } from '../../views/nad'; +import { brCrumbItem, itemFilter, row } from '../../views/selector-common'; + +describe('Check Ingresses page', () => { + it('create Ingress with YAML', () => { + cy.visit(`/k8s/ns/${TEST_NS}/ingresses`); + cy.checkTitle('Ingresses', MINUTE); + cy.byButtonText('Create Ingress').click(); + cy.get('.monaco-editor', { timeout: 30000 }).should('exist'); + cy.get(createBtn).click(); + cy.checkTitle(EXAMPLE); + checkActionMenu('Ingress'); + cy.contains(brCrumbItem, 'Ingresses').find('a').click(); + cy.get(itemFilter).clear(); + cy.get(itemFilter).type(EXAMPLE); + cy.contains(row, EXAMPLE).should('exist'); + }); +}); diff --git a/ui-tests-cy/tests/networking/nad-bridge.cy.ts b/ui-tests-cy/tests/networking/nad-bridge.cy.ts new file mode 100644 index 00000000..e77949fa --- /dev/null +++ b/ui-tests-cy/tests/networking/nad-bridge.cy.ts @@ -0,0 +1,9 @@ +import { adminOnlyDescribe } from '../../utils/const/base'; +import { NAD_BRIDGE } from '../../utils/const/nad'; +import { createNAD } from '../../views/nad'; + +adminOnlyDescribe('Test linux bridge NAD', () => { + it('create NAD with MAC Spoof checked', () => { + createNAD(NAD_BRIDGE); + }); +}); diff --git a/ui-tests-cy/tests/networking/nad-localnet.cy.ts b/ui-tests-cy/tests/networking/nad-localnet.cy.ts new file mode 100644 index 00000000..a92c4b1d --- /dev/null +++ b/ui-tests-cy/tests/networking/nad-localnet.cy.ts @@ -0,0 +1,14 @@ +import { adminOnlyDescribe } from '../../utils/const/base'; +import { NAD_LOCALNET } from '../../utils/const/nad'; +import { createNAD, deleteNAD } from '../../views/nad'; + +adminOnlyDescribe('Test secondary localnet NAD', () => { + it('create NAD with secondary localnet network', () => { + createNAD(NAD_LOCALNET); + }); + + it('delete NAD', () => { + cy.visitNAD(); + deleteNAD(NAD_LOCALNET.name); + }); +}); diff --git a/ui-tests-cy/tests/networking/nad-ovn.cy.ts b/ui-tests-cy/tests/networking/nad-ovn.cy.ts new file mode 100644 index 00000000..dae5402c --- /dev/null +++ b/ui-tests-cy/tests/networking/nad-ovn.cy.ts @@ -0,0 +1,9 @@ +import { adminOnlyDescribe } from '../../utils/const/base'; +import { NAD_OVN } from '../../utils/const/nad'; +import { createNAD } from '../../views/nad'; + +adminOnlyDescribe('Test L2 overlay NAD', () => { + it('create NAD with L2 overlay network', () => { + createNAD(NAD_OVN); + }); +}); diff --git a/ui-tests-cy/tests/networking/net-policies.cy.ts b/ui-tests-cy/tests/networking/net-policies.cy.ts new file mode 100644 index 00000000..ca9edb24 --- /dev/null +++ b/ui-tests-cy/tests/networking/net-policies.cy.ts @@ -0,0 +1,31 @@ +import { MINUTE, TEST_NS, TEST_POL_NAME } from '../../utils/const/base'; +import { checkActionMenu, nameInput } from '../../views/actions'; +import { brCrumbItem, egressOff, ingressOff, itemFilter, row } from '../../views/selector-common'; + +const denyTraffic = () => { + cy.get(ingressOff).check(); + cy.get('body').then(($body) => { + if ($body.find(egressOff).length) { + cy.get(egressOff).check(); + } + }); +}; + +describe('Check NetworkPolicies page', () => { + it('create NetworkPolicy with form', () => { + cy.visit(`/k8s/ns/${TEST_NS}/networkpolicies`); + cy.checkTitle('NetworkPolicies', MINUTE); + cy.byButtonText('Create NetworkPolicy').click(); + cy.get('input#form').check(); + cy.get(nameInput, { timeout: 10000 }).should('be.visible').clear(); + cy.get(nameInput).type(TEST_POL_NAME); + cy.get(nameInput).should('have.value', TEST_POL_NAME); + denyTraffic(); + cy.clickBtn('Create'); + cy.checkTitle(TEST_POL_NAME); + checkActionMenu('NetworkPolicy'); + cy.contains(brCrumbItem, 'NetworkPolicy').find('a').click(); + cy.get(itemFilter).type(TEST_POL_NAME); + cy.contains(row, TEST_POL_NAME).should('exist'); + }); +}); diff --git a/ui-tests-cy/tests/networking/routes.cy.ts b/ui-tests-cy/tests/networking/routes.cy.ts new file mode 100644 index 00000000..3ea07dd9 --- /dev/null +++ b/ui-tests-cy/tests/networking/routes.cy.ts @@ -0,0 +1,32 @@ +import { EXAMPLE, MINUTE, TEST_NS, TEST_ROUTE_NAME } from '../../utils/const/base'; +import { checkActionMenu, nameInput } from '../../views/actions'; +import { createBtn } from '../../views/nad'; +import { brCrumbItem, itemFilter, row } from '../../views/selector-common'; + +describe('Check Routes page', () => { + it('create Route with form', () => { + cy.exec( + `oc get service ${EXAMPLE} -n ${TEST_NS} 2>/dev/null || oc create service clusterip ${EXAMPLE} --tcp=80:80 -n ${TEST_NS}`, + { failOnNonZeroExit: false }, + ); + cy.visit(`/k8s/ns/${TEST_NS}/routes`); + cy.checkTitle('Routes', MINUTE); + cy.byButtonText('Create Route').click(); + cy.get('input#form').check(); + cy.get(nameInput).clear(); + cy.get(nameInput).type(TEST_ROUTE_NAME); + cy.byButtonText('Select a Service').click(); + cy.byButtonText(EXAMPLE).click(); + cy.byButtonText('Select target port').click(); + cy.get('#target-port').within(() => { + cy.get('button').click(); + }); + cy.get(createBtn).click(); + cy.checkTitle(TEST_ROUTE_NAME); + checkActionMenu('Route'); + cy.contains(brCrumbItem, 'Routes').find('a').click(); + cy.get(itemFilter).clear(); + cy.get(itemFilter).type(TEST_ROUTE_NAME); + cy.contains(row, TEST_ROUTE_NAME).should('exist'); + }); +}); diff --git a/ui-tests-cy/tests/networking/services.cy.ts b/ui-tests-cy/tests/networking/services.cy.ts new file mode 100644 index 00000000..8f502647 --- /dev/null +++ b/ui-tests-cy/tests/networking/services.cy.ts @@ -0,0 +1,19 @@ +import { EXAMPLE, MINUTE, TEST_NS } from '../../utils/const/base'; +import { checkActionMenu } from '../../views/actions'; +import { createBtn } from '../../views/nad'; +import { brCrumbItem, itemFilter, row } from '../../views/selector-common'; + +describe('Check Services page', () => { + it('create Service with YAML', () => { + cy.visit(`/k8s/ns/${TEST_NS}/services`); + cy.checkTitle('Services', MINUTE); + cy.byButtonText('Create Service').click(); + cy.get('.monaco-editor', { timeout: 30000 }).should('exist'); + cy.get(createBtn).click(); + cy.checkTitle(EXAMPLE); + checkActionMenu('Service'); + cy.contains(brCrumbItem, 'Services').find('a').click(); + cy.get(itemFilter).type(EXAMPLE); + cy.contains(row, EXAMPLE).should('exist'); + }); +}); diff --git a/ui-tests-cy/tests/networking/udn.cy.ts b/ui-tests-cy/tests/networking/udn.cy.ts new file mode 100644 index 00000000..cc6d5599 --- /dev/null +++ b/ui-tests-cy/tests/networking/udn.cy.ts @@ -0,0 +1,46 @@ +import { adminOnlyDescribe, MINUTE, UDN_NS } from '../../utils/const/base'; +import { checkActionMenu } from '../../views/actions'; +import { brCrumbItem } from '../../views/selector-common'; +import { createUDN } from '../../views/udn'; + +const SUBNET = '192.168.1.1/24'; +const UDN_CLST = 'cluster-udn'; + +adminOnlyDescribe('Test UDN', () => { + it('create UDN', () => { + createUDN(UDN_NS, SUBNET); + }); + + it('create CUDN', () => { + cy.visit('/k8s/all-namespaces/k8s.ovn.org~v1~UserDefinedNetwork'); + cy.checkTitle('UserDefinedNetworks', MINUTE); + cy.get( + '[data-test="item-create"] .pf-v6-c-menu-toggle__controls, [data-test="item-create"] [aria-label="Menu toggle"]', + ).click(); + cy.contains('ClusterUserDefinedNetwork').click(); + cy.byTestID('input-name', { timeout: 30000 }).should('be.visible'); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1000); + cy.byTestID('input-name').clear(); + cy.byTestID('input-name').type(UDN_CLST); + cy.byTestID('input-udn-subnet').clear(); + cy.byTestID('input-udn-subnet').type(SUBNET); + cy.byTestID('tags-input').clear(); + cy.byTestID('tags-input').type(`kubernetes.io/metadata.name=${UDN_NS}`); + cy.byButtonText('Review selected').click(); + cy.byTestID('create-udn-submit').click(); + cy.checkTitle(UDN_CLST); + checkActionMenu('ClusterUserDefinedNetwork'); + cy.contains(brCrumbItem, 'ClusterUserDefinedNetworks').find('a').eq(0).click(); + }); + + it('delete CUDN', () => { + cy.visit('/k8s/all-namespaces/k8s.ovn.org~v1~UserDefinedNetwork'); + cy.checkTitle('UserDefinedNetworks', MINUTE); + cy.contains('[data-label="name"]', UDN_CLST).find('a').click(); + cy.byButtonText('Actions').click(); + cy.get('[data-test-action="Delete ClusterUserDefinedNetwork"]').click(); + cy.byTestID('confirm-action').click(); + cy.contains(UDN_CLST).should('not.exist'); + }); +}); diff --git a/ui-tests-cy/tests/setup/login.cy.ts b/ui-tests-cy/tests/setup/login.cy.ts new file mode 100644 index 00000000..00492e98 --- /dev/null +++ b/ui-tests-cy/tests/setup/login.cy.ts @@ -0,0 +1,5 @@ +describe('Login', () => { + it('login to OpenShift console', () => { + cy.login(); + }); +}); diff --git a/ui-tests-cy/tests/setup/visit-pages.cy.ts b/ui-tests-cy/tests/setup/visit-pages.cy.ts new file mode 100644 index 00000000..6ec553f9 --- /dev/null +++ b/ui-tests-cy/tests/setup/visit-pages.cy.ts @@ -0,0 +1,35 @@ +import { MINUTE } from '../../utils/const/base'; + +describe('Visit networking pages', () => { + it('visit NetworkAttachmentDefinitions page', () => { + cy.get('[data-quickstart-id="qs-nav-networking"]', { timeout: MINUTE }).scrollIntoView(); + cy.contains('Networking').should('be.visible'); + cy.clickNavLink(['Networking', 'NetworkAttachmentDefinitions']); + cy.checkTitle('NetworkAttachmentDefinitions', MINUTE); + }); + + it('visit UserDefinedNetworks page', () => { + cy.clickNavLink(['Networking', 'UserDefinedNetworks']); + cy.checkTitle('UserDefinedNetworks', MINUTE); + }); + + it('visit Services page', () => { + cy.clickNavLink(['Networking', 'Services']); + cy.checkTitle('Services', MINUTE); + }); + + it('visit Routes page', () => { + cy.clickNavLink(['Networking', 'Routes']); + cy.checkTitle('Routes', MINUTE); + }); + + it('visit Ingresses page', () => { + cy.clickNavLink(['Networking', 'Ingresses']); + cy.checkTitle('Ingresses', MINUTE); + }); + + it('visit NetworkPolicies page', () => { + cy.clickNavLink(['Networking', 'NetworkPolicies']); + cy.checkTitle('NetworkPolicies', MINUTE); + }); +}); diff --git a/ui-tests-cy/tsconfig.json b/ui-tests-cy/tsconfig.json new file mode 100644 index 00000000..c8f71161 --- /dev/null +++ b/ui-tests-cy/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types":["cypress","node"], + "isolatedModules": false + }, + "include": ["../node_modules/cypress", "./**/*.ts"] +} \ No newline at end of file diff --git a/ui-tests-cy/utils/const/base.ts b/ui-tests-cy/utils/const/base.ts new file mode 100644 index 00000000..432cbbfd --- /dev/null +++ b/ui-tests-cy/utils/const/base.ts @@ -0,0 +1,23 @@ +export const adminOnlyDescribe = Cypress.env('NON_PRIV') ? xdescribe : describe; +export const adminOnlyIT = Cypress.env('NON_PRIV') ? xit : it; + +export const TEST_NS = Cypress.env('TEST_NS') || 'cy-test-ns'; +export const UDN_NS = Cypress.env('UDN_NS') || 'udn-test-ns'; + +export const EXAMPLE = 'example'; +export const ALL_PROJ_NS = 'All Projects'; +export const TEST_ROUTE_NAME = 'auto-test-route'; +export const TEST_POL_NAME = 'auto-test-net-policy'; +export const TEST_MULTI_NAME = 'auto-test-multi-policy'; + +export const SECOND = 1000; +export const MINUTE = 60000; + +export const UDN_LABEL = 'k8s.ovn.org/primary-user-defined-network'; + +export enum K8S_KIND { + CUDN = 'ClusterUserDefinedNetwork', + NAD = 'net-attach-def', + Project = 'project', + UDN = 'UserDefinedNetwork', +} diff --git a/ui-tests-cy/utils/const/nad.ts b/ui-tests-cy/utils/const/nad.ts new file mode 100644 index 00000000..96cc3434 --- /dev/null +++ b/ui-tests-cy/utils/const/nad.ts @@ -0,0 +1,28 @@ +import { NadData } from '../types/nad'; + +export const bridge = 'br0'; + +export const NAD_BRIDGE: NadData = { + bridge: bridge, + description: 'bridge nad', + macSpoof: true, + name: 'network-bridge', + type: 'Bridge', + vlan: '100', +}; + +export const NAD_OVN: NadData = { + description: 'ovn nad', + name: 'network-ovn', + type: 'OVN', + vlan: '200', +}; + +export const NAD_LOCALNET: NadData = { + bridge: bridge, + description: 'localnet nad', + mtu: '1500', + name: 'network-localnet', + type: 'Localnet', + vlan: '300', +}; diff --git a/ui-tests-cy/utils/types/nad.ts b/ui-tests-cy/utils/types/nad.ts new file mode 100644 index 00000000..58116cb6 --- /dev/null +++ b/ui-tests-cy/utils/types/nad.ts @@ -0,0 +1,11 @@ +export type NadData = { + name?: string; + description?: string; + type?: string; + bridge?: string; + vlan?: string; + mtu?: string; + macSpoof?: boolean; + subnet?: string; + exclude?: string; +}; diff --git a/ui-tests-cy/views/actions.ts b/ui-tests-cy/views/actions.ts new file mode 100644 index 00000000..f41d8972 --- /dev/null +++ b/ui-tests-cy/views/actions.ts @@ -0,0 +1,12 @@ +export const nameInput = 'input#name'; + +export const getRow = (name: string, within: VoidFunction) => + cy.byTestRows('resource-row').contains(name).parents('tr').within(within); + +export const checkActionMenu = (item: string) => { + cy.byButtonText('Actions').click(); + cy.contains('button', 'Edit labels').should('exist'); + cy.contains('button', 'Edit annotations').should('exist'); + cy.contains('button', `Edit ${item}`).should('exist'); + cy.contains('button', `Delete ${item}`).should('exist'); +}; diff --git a/ui-tests-cy/views/nad.ts b/ui-tests-cy/views/nad.ts new file mode 100644 index 00000000..f7e2e63d --- /dev/null +++ b/ui-tests-cy/views/nad.ts @@ -0,0 +1,77 @@ +import { NadData } from '../utils/types/nad'; + +import { row } from './selector-common'; + +export const name = 'input[name="name"]'; +export const description = 'input[name="description"]'; +export const type = '#toggle-nads-network-type'; +export const cnvBridgeLink = '[data-test-dropdown-menu="cnv-bridge"]'; +export const ovnNetwork = 'button[id="ovn-k8s-cni-overlay-link"]'; +export const localnet = 'button[id="ovn-k8s-cni-overlay-localnet-link"]'; +export const bridgeName = 'input[name="bridge.bridge"]'; +export const bridgeVlan = 'input[name="bridge.vlanTagNum"]'; +export const bridgeMap = 'input[name="ovn-k8s-cni-overlay-localnet.bridgeMapping"]'; +export const bridgeMTU = 'input[name="ovn-k8s-cni-overlay-localnet.mtu"]'; +export const localnetVlan = 'input[name="ovn-k8s-cni-overlay-localnet.vlanID"]'; +export const confirmBtn = '[data-test="confirm-action"]'; +export const heading = '[data-test-section-heading="NetworkAttachmentDefinition details"]'; +export const createBtn = '#save-changes'; +export const macSpoofCHK = 'input[id="bridge.macspoofchk"]'; +export const ovnSubnet = 'input[name="ovn-k8s-cni-overlay.subnets"]'; +export const localnetSubnet = 'input[name="ovn-k8s-cni-overlay-localnet.subnets"]'; +export const exclude = 'input[name="ovn-k8s-cni-overlay-localnet.excludeSubnets"]'; + +export const createNAD = (nad: NadData) => { + cy.visitNAD(); + cy.byButtonText('Create NetworkAttachmentDefinition').click(); + cy.get('#form').check(); + cy.get(name, { timeout: 10000 }).should('be.visible'); + cy.get(name).clear().type(nad.name); + cy.get(description).clear().type(nad.description); + cy.get(type).click(); + switch (nad.type) { + case 'Bridge': { + cy.byButtonText('Linux bridge').click(); + cy.get(bridgeName).clear().type(nad.bridge); + if (nad.vlan) { + cy.get(bridgeVlan).clear().type(nad.vlan); + } + if (!nad.macSpoof) { + cy.get(macSpoofCHK).uncheck(); + } + break; + } + case 'OVN': { + cy.byButtonText('L2 overlay').click(); + if (nad.subnet) { + cy.get(ovnSubnet).type(nad.subnet); + } + break; + } + case 'Localnet': { + cy.byButtonText('secondary localnet').click(); + cy.get(bridgeMap).clear().type(nad.bridge); + cy.get(bridgeMTU).clear().type(nad.mtu); + if (nad.vlan) { + cy.get(localnetVlan).clear().type(nad.vlan); + } + if (nad.subnet) { + cy.get(localnetSubnet).type(nad.subnet); + cy.get(exclude).type(nad.exclude); + } + break; + } + } + cy.get(createBtn).click(); + cy.get(heading).should('exist'); + cy.contains('[data-test-selector="details-item-label__Description"]', 'Description') + .parent() + .should('contain', nad.description); +}; + +export const deleteNAD = (nadName: string) => { + cy.contains(row, nadName).find('.pf-v6-c-table__action').find('button').click(); + cy.byButtonText('Delete').click(); + cy.get(confirmBtn).click(); + cy.contains(row, nadName).should('not.exist'); +}; diff --git a/ui-tests-cy/views/selector-common.ts b/ui-tests-cy/views/selector-common.ts new file mode 100644 index 00000000..77ed1559 --- /dev/null +++ b/ui-tests-cy/views/selector-common.ts @@ -0,0 +1,14 @@ +export const row = '[data-test-rows="resource-row"]'; +export const actionsBtn = 'button.pf-v6-c-menu-toggle.pf-m-plain'; +export const itemCreateBtn = '[data-test="item-create"]'; +export const labelContent = '.pf-v6-c-label__content'; +export const brCrumbItem = '[aria-label="Breadcrumb"]'; +export const itemFilter = '[data-test-id="item-filter"]'; +export const createBtn = '#save-changes'; +export const confirmBtn = '[data-test="confirm-action"]'; +export const dropDownItem = '.pf-v6-c-dropdown__menu-item'; + +export const ingressHeader = '#ingress-header'; +export const egressHeader = '#egress-header'; +export const ingressOff = '#denyAllIngress'; +export const egressOff = '#denyAllEgress'; diff --git a/ui-tests-cy/views/udn.ts b/ui-tests-cy/views/udn.ts new file mode 100644 index 00000000..8b063796 --- /dev/null +++ b/ui-tests-cy/views/udn.ts @@ -0,0 +1,39 @@ +import { ALL_PROJ_NS } from '../utils/const/base'; + +import { checkActionMenu } from './actions'; +import { row } from './selector-common'; +import { brCrumbItem } from './selector-common'; + +export const UDN_PRIM_UDN_NS = 'primary-udn'; + +export const createUDN = (project: string, subnet: string) => { + cy.visitUDN(); + cy.byButtonText('Create').click(); + cy.get('body').then(($body) => { + if ($body.find('[data-test="list-page-create-dropdown-item-UserDefinedNetwork"]').length) { + cy.get('[data-test="list-page-create-dropdown-item-UserDefinedNetwork"]') + .find('button') + .click(); + } + }); + cy.checkTitle('Create UserDefinedNetwork'); + cy.get('#typeahead-select-input').find('input').type(project); + cy.get(`#select-typeahead-${project}`, { timeout: 10000 }).click(); + cy.byTestID('input-udn-subnet').clear(); + cy.byTestID('input-udn-subnet').type(subnet); + cy.byTestID('create-udn-submit').should('not.be.disabled').click(); + cy.checkTitle(UDN_PRIM_UDN_NS); + checkActionMenu('UserDefinedNetwork'); + cy.contains(brCrumbItem, 'UserDefinedNetworks').find('a').eq(0).click(); + cy.contains(row, UDN_PRIM_UDN_NS).should('exist'); +}; + +export const deleteUDN = () => { + cy.visitUDN(); + cy.switchProject(ALL_PROJ_NS); + cy.contains('[data-label="name"]', UDN_PRIM_UDN_NS).find('a').click(); + cy.byButtonText('Actions').click(); + cy.get('[data-test-action="Delete UserDefinedNetwork"]').click(); + cy.byTestID('confirm-action').click(); + cy.contains(UDN_PRIM_UDN_NS).should('not.exist'); +};