diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..df164d54 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +node_modules +dist +.idea +.vscode +.devcontainer +.github +ci-scripts +ui-tests-cy +integration-tests +problem +.env 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/hot-cluster-e2e-run.yml b/.github/workflows/hot-cluster-e2e-run.yml new file mode 100644 index 00000000..e88fd452 --- /dev/null +++ b/.github/workflows/hot-cluster-e2e-run.yml @@ -0,0 +1,301 @@ +name: Hot Cluster E2E Run + +on: + workflow_dispatch: + inputs: + test_spec: + description: Cypress test spec to run + required: true + default: tests/all.cy.ts + type: string + workflow_call: + inputs: + test_spec: + description: Cypress test spec to run + type: string + required: false + default: tests/all.cy.ts + +permissions: + contents: read + actions: read + +env: + TEST_NS: networking-ci-test-${{ github.run_id }} + UDN_NS: networking-ci-udn-${{ github.run_id }} + + PLUGIN_IMAGE: 'ttl.sh/networking-console-plugin-ci-${{ github.run_id }}-${{ github.run_attempt }}:2h' + + CI_ENV_NS: ci-env + CI_ENV_CM: ci-env-${{ github.run_id }} + +jobs: + check-runner: + name: Check Runner Image + runs-on: networking-console-plugin-ci + timeout-minutes: 15 + env: + OPENSSL_FORCE_FIPS_MODE: '0' + GOLANG_FIPS: '0' + KUBECONFIG: /tmp/kubeconfig + steps: + - name: Install oc CLI + run: | + if ! command -v oc &>/dev/null; then + OC_DIR="${RUNNER_TEMP}/oc-bin" + mkdir -p "${OC_DIR}" + cd "${OC_DIR}" + curl -sL "https://mirror.openshift.com/pub/openshift-v4/x86_64/clients/ocp/stable/openshift-client-linux.tar.gz" -o oc.tar.gz + tar -xzf oc.tar.gz oc kubectl + chmod +x oc kubectl + echo "${OC_DIR}" >> "$GITHUB_PATH" + export PATH="${OC_DIR}:${PATH}" + fi + oc version --client + + - name: Authenticate to cluster + env: + CLUSTER_API: ${{ secrets.CLUSTER_API }} + CLUSTER_TOKEN: ${{ secrets.CLUSTER_TOKEN }} + run: | + oc login "${CLUSTER_API}" --token="${CLUSTER_TOKEN}" --insecure-skip-tls-verify + + - name: Log environment summary + run: | + { + echo "
Key Environment Variables" + echo "" + echo "| Variable | Value |" + echo "| --- | --- |" + for var in HOME USER RUNNER_NAME RUNNER_OS RUNNER_ARCH \ + GITHUB_REPOSITORY GITHUB_REF GITHUB_SHA GITHUB_RUN_ID GITHUB_RUN_NUMBER \ + TEST_NS UDN_NS PLUGIN_IMAGE; do + echo "| \`$var\` | \`${!var:-}\` |" + done + echo "
" + echo "" + + echo "
Tool Availability" + echo "" + echo "| Tool | Available |" + echo "| --- | --- |" + missing=0 + for cmd in jq curl kubectl oc helm npm node; do + if command -v "$cmd" &>/dev/null; then + echo "| \`$cmd\` | ✅ |" + else + echo "| \`$cmd\` | ❌ |" + missing=1 + fi + done + echo "
" + echo "" + if [[ "${missing}" -ne 0 ]]; then + echo "::error::Required tools are missing on the ARC runner" + exit 1 + fi + + echo "
npm / Node Versions" + echo "" + echo "\`\`\`json" + npm version --json 2>/dev/null || echo "npm not found" + echo "\`\`\`" + echo "
" + echo "" + } | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Log Client / Server Versions + run: | + { + echo "
Client / Server Versions" + echo "" + echo "| Tool | Client Version | Server Version |" + echo "| --- | --- | --- |" + failed=0 + for cmd in oc; do + if command -v "$cmd" &>/dev/null; then + if ! version_output=$("$cmd" version 2>/dev/null); then + echo "| \`$cmd\` | ❌ version failed | ❌ |" + failed=1 + continue + fi + client=$(echo "$version_output" | grep -i "client" | head -1 | sed 's/^[[:space:]]*//') + server=$(echo "$version_output" | grep -i "server" | head -1 | sed 's/^[[:space:]]*//') + echo "| \`$cmd\` | ${client:-N/A} | ${server:-N/A} |" + else + echo "| \`$cmd\` | ❌ not found | — |" + failed=1 + fi + done + echo "
" + echo "" + } | tee -a "$GITHUB_STEP_SUMMARY" + if [[ "${failed}" -ne 0 ]]; then + echo "::error::Client/server version checks failed" + exit 1 + fi + + build-plugin-image: + name: Build Plugin Image + runs-on: ubuntu-latest + outputs: + plugin-image: ${{ env.PLUGIN_IMAGE }} + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + + - name: Check if plugin image exists in registry + id: check_image + run: | + if skopeo inspect docker://${PLUGIN_IMAGE} &>/dev/null; then + echo "IMAGE_EXISTS=true" >> $GITHUB_OUTPUT + else + echo "IMAGE_EXISTS=false" >> $GITHUB_OUTPUT + fi + + - name: Build and push + if: steps.check_image.outputs.IMAGE_EXISTS == 'false' + run: | + podman build -t ${PLUGIN_IMAGE} -f Dockerfile.ci . + podman push ${PLUGIN_IMAGE} + + run-e2e-tests: + name: Run E2E Tests + needs: [check-runner, build-plugin-image] + runs-on: networking-console-plugin-ci + timeout-minutes: 60 + env: + BRIDGE_E2E_BROWSER_NAME: electron + OPENSSL_FORCE_FIPS_MODE: '0' + GOLANG_FIPS: '0' + KUBECONFIG: /tmp/kubeconfig + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + + - name: Install oc CLI + run: | + if ! command -v oc &>/dev/null; then + OC_DIR="${RUNNER_TEMP}/oc-bin" + mkdir -p "${OC_DIR}" + cd "${OC_DIR}" + curl -sL "https://mirror.openshift.com/pub/openshift-v4/x86_64/clients/ocp/stable/openshift-client-linux.tar.gz" -o oc.tar.gz + tar -xzf oc.tar.gz oc kubectl + chmod +x oc kubectl + echo "${OC_DIR}" >> "$GITHUB_PATH" + export PATH="${OC_DIR}:${PATH}" + fi + oc version --client + + - name: Authenticate to cluster + env: + CLUSTER_API: ${{ secrets.CLUSTER_API }} + CLUSTER_TOKEN: ${{ secrets.CLUSTER_TOKEN }} + run: | + oc login "${CLUSTER_API}" --token="${CLUSTER_TOKEN}" --insecure-skip-tls-verify + oc cluster-info + oc get nodes --no-headers | wc -l | xargs -I{} echo "{} node(s) ready" + + - name: Provision CI test environment + id: ci-env + uses: ./.github/actions/ci-env-request + with: + plugin-image: ${{ env.PLUGIN_IMAGE }} + test-namespace: ${{ env.TEST_NS }} + configmap-name: ${{ env.CI_ENV_CM }} + ci-env-namespace: ${{ env.CI_ENV_NS }} + + - name: Set up test namespaces + run: | + UDN_LABEL="k8s.ovn.org/primary-user-defined-network" + + oc get namespace ${TEST_NS} 2>/dev/null || oc create namespace ${TEST_NS} + + if ! oc get namespace ${UDN_NS} 2>/dev/null; then + cat </dev/null || echo "${CI_ENV_CM}")" + + oc logs -n "${TEST_NS}" -l "app=${HELM_RELEASE}-console" --tail=-1 \ + > "${TMP}/console.log" 2>&1 || true + oc logs -n "${TEST_NS}" -l "app=${HELM_RELEASE}-plugin" --tail=-1 \ + > "${TMP}/networking-plugin.log" 2>&1 || true + + - name: Collect CI diagnostics — cluster info + if: failure() + run: | + TMP=/tmp/e2e-ci-diagnostics/cluster + mkdir -p "${TMP}" + + oc get nodes -o wide > "${TMP}/nodes.txt" 2>/dev/null || true + oc get events -n "${TEST_NS}" --sort-by='.lastTimestamp' > "${TMP}/test_ns_events.txt" 2>/dev/null || true + oc get pods -n "${TEST_NS}" -o wide > "${TMP}/test_ns_pods.txt" 2>/dev/null || true + + - name: Upload CI diagnostics + if: always() + uses: actions/upload-artifact@v7 + with: + name: e2e-ci-diagnostics-${{ github.run_id }} + path: /tmp/e2e-ci-diagnostics/ + retention-days: 7 + if-no-files-found: ignore + + - name: Clean up test resources + if: always() + run: bash ci-scripts/test-cleanup.sh + + - name: Release CI test environment + if: always() + uses: ./.github/actions/ci-env-release + with: + configmap-name: ${{ env.CI_ENV_CM }} + ci-env-namespace: ${{ env.CI_ENV_NS }} diff --git a/.github/workflows/hot-cluster-e2e.yml b/.github/workflows/hot-cluster-e2e.yml new file mode 100644 index 00000000..bb4622c1 --- /dev/null +++ b/.github/workflows/hot-cluster-e2e.yml @@ -0,0 +1,26 @@ +name: Hot Cluster E2E + +on: + workflow_dispatch: + inputs: + test_spec: + description: Cypress test spec to run + required: true + default: tests/all.cy.ts + type: string + +permissions: + contents: read + actions: read + +concurrency: + group: hot-cluster-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + run-e2e-tests: + name: Run E2E Tests + uses: ./.github/workflows/hot-cluster-e2e-run.yml + with: + test_spec: ${{ inputs.test_spec }} + secrets: inherit diff --git a/Dockerfile.ci b/Dockerfile.ci new file mode 100644 index 00000000..752bb5ac --- /dev/null +++ b/Dockerfile.ci @@ -0,0 +1,25 @@ +# CI Dockerfile — mirrors production Dockerfile but uses public registries +# (GitHub Actions runners cannot pull from registry.ci.openshift.org) + +# Builder container +FROM registry.access.redhat.com/ubi9/nodejs-18:1-118 AS build + +COPY . /opt/app-root/src/app +WORKDIR /opt/app-root/src/app + +USER 0 +ENV CYPRESS_INSTALL_BINARY=0 +RUN npm clean-install --ignore-scripts --no-audit && npm run build + +# Web server container +FROM registry.access.redhat.com/ubi9/ubi-minimal:9.4 + +RUN microdnf install -y nginx && microdnf clean all && \ + chown -R 1001:0 /var/lib/nginx /var/log/nginx /run && \ + chmod -R ug+rwX /var/lib/nginx /var/log/nginx /run + +USER 1001 + +COPY --from=build /opt/app-root/src/app/dist /opt/app-root/src + +CMD nginx -g "daemon off;"