From 652731afead9f3a1856572da00090d048a897299 Mon Sep 17 00:00:00 2001 From: albarbaro Date: Wed, 19 Aug 2026 12:18:28 +0200 Subject: [PATCH 1/8] enable fips job --- .ci/pipelines/jobs/ocp-fips-helm.sh | 63 ++++++ .ci/pipelines/lib/config.sh | 2 + .ci/pipelines/openshift-ci-tests.sh | 7 + .../config_map/app-config-rhdh-fips.yaml | 210 ++++++++++++++++++ .../diff-values_showcase-fips.yaml | 27 +++ e2e-tests/playwright.config.ts | 19 ++ e2e-tests/playwright/projects.json | 1 + e2e-tests/playwright/projects.ts | 1 + 8 files changed, 330 insertions(+) create mode 100644 .ci/pipelines/jobs/ocp-fips-helm.sh create mode 100644 .ci/pipelines/resources/config_map/app-config-rhdh-fips.yaml create mode 100644 .ci/pipelines/value_files/diff-values_showcase-fips.yaml diff --git a/.ci/pipelines/jobs/ocp-fips-helm.sh b/.ci/pipelines/jobs/ocp-fips-helm.sh new file mode 100644 index 0000000000..7e91a8c804 --- /dev/null +++ b/.ci/pipelines/jobs/ocp-fips-helm.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +# shellcheck source=.ci/pipelines/lib/log.sh +source "$DIR"/lib/log.sh +# shellcheck source=.ci/pipelines/lib/common.sh +source "$DIR"/lib/common.sh +# shellcheck source=.ci/pipelines/utils.sh +source "$DIR"/utils.sh +# shellcheck source=.ci/pipelines/lib/testing.sh +source "$DIR"/lib/testing.sh +# shellcheck source=.ci/pipelines/playwright-projects.sh +source "$DIR"/playwright-projects.sh + +handle_ocp_fips_helm() { + export NAME_SPACE="${NAME_SPACE:-showcase-fips-nightly}" + + common::oc_login + + K8S_CLUSTER_ROUTER_BASE=$(oc get route console -n openshift-console -o=jsonpath='{.spec.host}' | sed 's/^[^.]*\.//') + export K8S_CLUSTER_ROUTER_BASE + + cluster_setup_ocp_helm + + fips_deployment "${PW_PROJECT_SHOWCASE_FIPS}" + + deploy_test_backstage_customization_provider "${NAME_SPACE}" + + run_standard_deployment_tests +} + +# Same shape as base_deployment() in utils.sh, but merges diff-values_showcase-fips.yaml +# onto values_showcase.yaml, since base_deployment()/helm::install() are hardwired to +# the default values_showcase.yaml. +fips_deployment() { + common::require_vars "RELEASE_NAME" "TAG_NAME" "IMAGE_REGISTRY" "IMAGE_REPO" "K8S_CLUSTER_ROUTER_BASE" || return 1 + local artifacts_subdir=$1 + local fips_diff_value_file="${DIR}/value_files/diff-values_showcase-fips.yaml" + local fips_merged_value_file="/tmp/merged-values_showcase-fips.yaml" + + namespace::configure "${NAME_SPACE}" + + deploy_redis_cache "${NAME_SPACE}" + + cd "${DIR}" || exit + local rhdh_base_url="https://${RELEASE_NAME}-developer-hub-${NAME_SPACE}.${K8S_CLUSTER_ROUTER_BASE}" + apply_yaml_files "${DIR}" "${NAME_SPACE}" "${rhdh_base_url}" + + helm::merge_values "overwrite" "${DIR}/value_files/${HELM_CHART_VALUE_FILE_NAME}" "${fips_diff_value_file}" "${fips_merged_value_file}" + common::save_artifact "${artifacts_subdir}" "${fips_merged_value_file}" || true + + log::info "Deploying image from repository: ${IMAGE_REGISTRY}/${IMAGE_REPO}, TAG_NAME: ${TAG_NAME}, in NAME_SPACE: ${NAME_SPACE}" + # shellcheck disable=SC2046 + helm upgrade -i "${RELEASE_NAME}" -n "${NAME_SPACE}" \ + "${HELM_CHART_URL}" --version "${CHART_VERSION}" \ + -f "${fips_merged_value_file}" \ + --set global.clusterRouterBase="${K8S_CLUSTER_ROUTER_BASE}" \ + $(helm::get_image_params) +} + +run_standard_deployment_tests() { + local url="https://${RELEASE_NAME}-developer-hub-${NAME_SPACE}.${K8S_CLUSTER_ROUTER_BASE}" + testing::check_and_test "${RELEASE_NAME}" "${NAME_SPACE}" "${PW_PROJECT_SHOWCASE_FIPS}" "${url}" +} diff --git a/.ci/pipelines/lib/config.sh b/.ci/pipelines/lib/config.sh index aff007ca23..fbc3324cf3 100644 --- a/.ci/pipelines/lib/config.sh +++ b/.ci/pipelines/lib/config.sh @@ -60,6 +60,8 @@ config::select_config_map_file() { if [[ "${project}" == *rbac* ]]; then echo "$dir/resources/config_map/app-config-rhdh-rbac.yaml" + elif [[ "${project}" == *fips* ]]; then + echo "$dir/resources/config_map/app-config-rhdh-fips.yaml" else echo "$dir/resources/config_map/app-config-rhdh.yaml" fi diff --git a/.ci/pipelines/openshift-ci-tests.sh b/.ci/pipelines/openshift-ci-tests.sh index 3ffc4f94c5..0dabba0e3b 100755 --- a/.ci/pipelines/openshift-ci-tests.sh +++ b/.ci/pipelines/openshift-ci-tests.sh @@ -129,6 +129,13 @@ main() { log::info "Calling handle_ocp_localization" handle_ocp_localization ;; + *ocp*fips*helm*nightly*) + log::info "Sourcing ocp-fips-helm.sh" + # shellcheck source=.ci/pipelines/jobs/ocp-fips-helm.sh + source "${DIR}/jobs/ocp-fips-helm.sh" + log::info "Calling handle_ocp_fips_helm" + handle_ocp_fips_helm + ;; *ocp*helm*nightly*) log::info "Sourcing ocp-nightly.sh" # shellcheck source=.ci/pipelines/jobs/ocp-nightly.sh diff --git a/.ci/pipelines/resources/config_map/app-config-rhdh-fips.yaml b/.ci/pipelines/resources/config_map/app-config-rhdh-fips.yaml new file mode 100644 index 0000000000..b0b552205b --- /dev/null +++ b/.ci/pipelines/resources/config_map/app-config-rhdh-fips.yaml @@ -0,0 +1,210 @@ +app: + support: + url: https://github.com/redhat-developer/rhdh/issues + items: + - title: Red Hat Developer Hub + links: + - url: https://access.redhat.com/products/red-hat-developer-hub + title: Product Information + baseUrl: ${RHDH_BASE_URL} + title: Red Hat Developer Hub + branding: + fullLogo: # QE Red Hat Developer Hub + light: "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22160pt%22%20height%3D%2280pt%22%20viewBox%3D%220%200%20160%2080%22%3E%3Cg%20fill%3D%22%23000%22%20style%3D%22text-align%3Astart%3Btext-align-last%3Aauto%22%20letter-spacing%3D%220%22%3E%3Ctext%20font-family%3D%22Red%20Hat%20Display%22%20font-size%3D%2240%22%20font-weight%3D%22700%22%20transform%3D%22translate(-.177%2054.263)%22%20word-spacing%3D%220%22%3E%3Ctspan%20x%3D%220%22%3EQE%3C%2Ftspan%3E%3C%2Ftext%3E%3Ctext%20font-family%3D%22Red%20Hat%20Text%22%20font-size%3D%2214%22%20font-weight%3D%22700%22%20transform%3D%22translate(57.565%2035.73)%22%20word-spacing%3D%220%22%3E%3Ctspan%20x%3D%220%22%3ERed%20Hat%3C%2Ftspan%3E%3Ctspan%20x%3D%220%22%20dy%3D%2218.516%22%3EDeveloper%20Hub%3C%2Ftspan%3E%3C%2Ftext%3E%3C%2Fg%3E%3C%2Fsvg%3E" + dark: "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22160pt%22%20height%3D%2280pt%22%20viewBox%3D%220%200%20160%2080%22%3E%3Cg%20fill%3D%22%23fff%22%20style%3D%22text-align%3Astart%3Btext-align-last%3Aauto%22%20letter-spacing%3D%220%22%3E%3Ctext%20font-family%3D%22Red%20Hat%20Display%22%20font-size%3D%2240%22%20font-weight%3D%22700%22%20transform%3D%22translate(-.177%2054.263)%22%20word-spacing%3D%220%22%3E%3Ctspan%20x%3D%220%22%3EQE%3C%2Ftspan%3E%3C%2Ftext%3E%3Ctext%20font-family%3D%22Red%20Hat%20Text%22%20font-size%3D%2214%22%20font-weight%3D%22700%22%20transform%3D%22translate(57.565%2035.73)%22%20word-spacing%3D%220%22%3E%3Ctspan%20x%3D%220%22%3ERed%20Hat%3C%2Ftspan%3E%3Ctspan%20x%3D%220%22%20dy%3D%2218.516%22%3EDeveloper%20Hub%3C%2Ftspan%3E%3C%2Ftext%3E%3C%2Fg%3E%3C%2Fsvg%3E" + iconLogo: # QE icon + light: "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2280pt%22%20height%3D%2280pt%22%20viewBox%3D%220%200%2080%2080%22%3E%3Ctext%20fill%3D%22%23000%22%20font-family%3D%22Red%20Hat%20Mono%22%20font-size%3D%2264%22%20font-weight%3D%22700%22%20letter-spacing%3D%220%22%20style%3D%22text-align%3Astart%3Btext-align-last%3Aauto%22%20transform%3D%22translate(1.6%2062.813)%22%20word-spacing%3D%220%22%3E%3Ctspan%20x%3D%220%22%3EQE%3C%2Ftspan%3E%3C%2Ftext%3E%3C%2Fsvg%3E" + dark: "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2280pt%22%20height%3D%2280pt%22%20viewBox%3D%220%200%2080%2080%22%3E%3Ctext%20fill%3D%22%23fff%22%20font-family%3D%22Red%20Hat%20Mono%22%20font-size%3D%2264%22%20font-weight%3D%22700%22%20letter-spacing%3D%220%22%20style%3D%22text-align%3Astart%3Btext-align-last%3Aauto%22%20transform%3D%22translate(1.6%2062.813)%22%20word-spacing%3D%220%22%3E%3Ctspan%20x%3D%220%22%3EQE%3C%2Ftspan%3E%3C%2Ftext%3E%3C%2Fsvg%3E" + theme: + light: + primaryColor: "#2A61A7" + headerColor1: "rgb(216, 98, 208)" + headerColor2: "rgb(216, 164, 98)" + navigationIndicatorColor: "rgb(98, 216, 105)" + palette: + rhdh: + general: + sidebarItemSelectedBackgroundColor: "#f0f0f0" + dark: + primaryColor: "#DC6ED9" + headerColor1: "rgb(190, 122, 45)" + headerColor2: "rgb(45, 190, 50)" + navigationIndicatorColor: "rgb(45, 113, 190)" + palette: + rhdh: + general: + sidebarItemSelectedBackgroundColor: "#333333" +backend: + baseUrl: ${RHDH_BASE_URL} + cors: + origin: ${RHDH_BASE_URL} + reading: + allow: + - host: "github.com" + - host: ${DH_TARGET_URL} + auth: + dangerouslyDisableDefaultAuthPolicy: true + externalAccess: + - type: static + options: + token: test-token + subject: test-subject + keys: + - secret: ${BACKEND_SECRET} + cache: + store: redis + connection: redis://${REDIS_USERNAME}:${REDIS_PASSWORD}@redis:6379 + # redis sets are no longer supported from 1.5 + # useRedisSets: true +integrations: + # Plugin: GitHub + github: + - host: github.com + apps: + - appId: ${GITHUB_APP_APP_ID} + clientId: ${GITHUB_APP_CLIENT_ID} + clientSecret: ${GITHUB_APP_CLIENT_SECRET} + webhookUrl: ${GITHUB_APP_WEBHOOK_URL} + webhookSecret: ${GITHUB_APP_WEBHOOK_SECRET} + privateKey: | + ${GITHUB_APP_PRIVATE_KEY} + - appId: ${GITHUB_APP_JANUS_TEST_APP_ID} + clientId: ${GITHUB_APP_JANUS_TEST_CLIENT_ID} + clientSecret: ${GITHUB_APP_JANUS_TEST_CLIENT_SECRET} + webhookUrl: ${GITHUB_APP_WEBHOOK_URL} + webhookSecret: ${GITHUB_APP_WEBHOOK_SECRET} + privateKey: | + ${GITHUB_APP_JANUS_TEST_PRIVATE_KEY} + bitbucketServer: + - host: bitbucket.com + apiBaseUrl: temp + username: temp + password: temp +auth: + # see https://backstage.io/docs/auth/ to learn about auth providers + environment: development + session: + secret: superSecretSecret + providers: + guest: + userEntityRef: user:default/guest + dangerouslyAllowOutsideDevelopment: true + google: + development: + clientId: ${GOOGLE_CLIENT_ID} + clientSecret: ${GOOGLE_CLIENT_SECRET} + github: + development: + clientSecret: ${GITHUB_OAUTH_APP_SECRET} + clientId: ${GITHUB_OAUTH_APP_ID} + callbackUrl: ${RHDH_BASE_URL}/api/auth/github/handler/frame + oidc: + development: + metadataUrl: ${KEYCLOAK_AUTH_BASE_URL}/auth/realms/${KEYCLOAK_AUTH_REALM} + clientId: ${KEYCLOAK_AUTH_CLIENTID} + clientSecret: ${KEYCLOAK_AUTH_CLIENT_SECRET} + prompt: auto + callbackUrl: ${RHDH_BASE_URL}/api/auth/oidc/handler/frame + signIn: + resolvers: + - resolver: emailLocalPartMatchingUserEntityName +signInPage: oidc +proxy: + skipInvalidProxies: true + # endpoints: {} + endpoints: + # Other Proxies + "/quay/api": + target: https://quay.io/ + headers: + X-Requested-With: "XMLHttpRequest" + changeOrigin: true + secure: true + "/add-test-header": + target: ${RHDH_BASE_URL_HTTP}/api/simple-chat + credentials: forward + headers: + "x-proxy-test-header": "hello!" +catalog: + processingInterval: { hours: 24 } + import: + entityFilename: catalog-info.yaml + # pullRequestBranchName: rhdh-integration + pullRequestBranchName: backstage-integration + rules: + - allow: [API, Component, Group, Location, Resource, System, Template] + locations: + - type: url + target: https://github.com/redhat-developer/rhdh/blob/main/catalog-entities/all.yaml + - type: url + target: https://github.com/redhat-developer/red-hat-developer-hub-software-templates/blob/main/templates.yaml + - type: url + target: https://github.com/janus-qe/rhdh-test/blob/main/user.yml + rules: + - allow: [User] + - type: url + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme-corp.yaml + rules: + - allow: [User, Group] + providers: + # githubOrg: + # id: production + # githubUrl: "${GITHUB_URL}" + # orgs: ["${GITHUB_ORG}", "${GITHUB_ORG_2}"] + # Using Github GH_USER_ID account + keycloakOrg: + default: + baseUrl: ${KEYCLOAK_AUTH_BASE_URL}/auth + loginRealm: ${KEYCLOAK_AUTH_LOGIN_REALM} + realm: ${KEYCLOAK_AUTH_REALM} + clientId: ${KEYCLOAK_AUTH_CLIENTID} + clientSecret: ${KEYCLOAK_AUTH_CLIENT_SECRET} + schedule: + # Let's perform a single execution per test run + frequency: { hours: 24 } + timeout: { minutes: 1 } + badConfigForMetrics: + baseUrl: ${KEYCLOAK_AUTH_BASE_URL}/auth + loginRealm: ${KEYCLOAK_AUTH_LOGIN_REALM} + realm: ${KEYCLOAK_AUTH_REALM} + clientId: ${KEYCLOAK_AUTH_CLIENTID} + # Intentionally incorrect client secret for test purposes. + clientSecret: ABC + schedule: + # Let's perform a single execution to trigger the metrics fetch failure counter; next fetch will never happen again. + frequency: { hours: 24 } + timeout: { minutes: 1 } + initialDelay: { seconds: 15 } +dynatrace: + baseUrl: temp +argocd: + appLocatorMethods: + - type: "config" + instances: + - name: argoInstance1 + url: temp + token: temp + - name: argoInstance2 + url: temp + token: temp +permission: + enabled: false +buildInfo: + title: "RHDH Build info" + card: + TechDocs builder: "local" + Authentication provider: "Github" + RBAC: disabled + overrideBuildInfo: true +# Opt-out of database storage for user settings +userSettings: + persistence: browser +i18n: + locales: + - en + - de + - es + - fr + - it + - ja + defaultLocale: en diff --git a/.ci/pipelines/value_files/diff-values_showcase-fips.yaml b/.ci/pipelines/value_files/diff-values_showcase-fips.yaml new file mode 100644 index 0000000000..2d750a5edf --- /dev/null +++ b/.ci/pipelines/value_files/diff-values_showcase-fips.yaml @@ -0,0 +1,27 @@ +# This file is for FIPS installation only. +# It is applied on top of `values_showcase.yaml` via helm::merge_values (overwrite) and only +# contains complementary differences for FIPS. Note that it overwrites the whole key that is +# present in this file, so `upstream.backstage.extraEnvVars` must be restated in full even +# though only NODE_TLS_REJECT_UNAUTHORIZED changes (TLS verification must stay enabled in FIPS mode). +upstream: + backstage: + extraEnvVars: + - name: BACKEND_SECRET + valueFrom: + secretKeyRef: + key: backend-secret + name: '{{ include "rhdh.backend-secret-name" $ }}' + - name: POSTGRESQL_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + key: postgres-password + name: "{{ .Release.Name }}-postgresql" + # disable telemetry in CI + - name: SEGMENT_TEST_MODE + value: "true" + - name: NODE_TLS_REJECT_UNAUTHORIZED + value: "1" + - name: NODE_ENV + value: "production" + - name: ENABLE_CORE_ROOTHTTPROUTER_OVERRIDE + value: "true" diff --git a/e2e-tests/playwright.config.ts b/e2e-tests/playwright.config.ts index 9f055740b8..fe4d0fca82 100644 --- a/e2e-tests/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -102,6 +102,25 @@ export default defineConfig({ "**/playwright/e2e/configuration-test/config-map.spec.ts", ], }, + { + name: PW_PROJECT.SHOWCASE_FIPS, + timeout: 180 * 1000, + dependencies: [PW_PROJECT.SMOKE_TEST], + use: { + ignoreHTTPSErrors: false, + }, + testIgnore: [ + "**/playwright/seed.spec.ts", + "**/playwright/e2e/plugins/rbac/**/*.spec.ts", + "**/playwright/e2e/**/*-rbac.spec.ts", + "**/playwright/e2e/external-database/verify-tls-config-with-external-crunchy.spec.ts", + "**/playwright/e2e/auth-providers/**/*.spec.ts", + "**/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts", + "**/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts", + "**/playwright/e2e/plugin-division-mode-schema/*.spec.ts", + "**/playwright/e2e/configuration-test/config-map.spec.ts", + ], + }, { name: PW_PROJECT.SHOWCASE_RBAC, dependencies: [PW_PROJECT.SMOKE_TEST], diff --git a/e2e-tests/playwright/projects.json b/e2e-tests/playwright/projects.json index 567cc0098d..e324d53dac 100644 --- a/e2e-tests/playwright/projects.json +++ b/e2e-tests/playwright/projects.json @@ -1,6 +1,7 @@ { "SMOKE_TEST": "smoke-test", "SHOWCASE": "showcase", + "SHOWCASE_FIPS": "showcase-fips", "SHOWCASE_RBAC": "showcase-rbac", "ANY_TEST": "any-test", "SHOWCASE_K8S": "showcase-k8s", diff --git a/e2e-tests/playwright/projects.ts b/e2e-tests/playwright/projects.ts index 52751e0793..1aad0afffd 100644 --- a/e2e-tests/playwright/projects.ts +++ b/e2e-tests/playwright/projects.ts @@ -15,6 +15,7 @@ import projectsJson from "./projects.json" with { type: "json" }; export const PW_PROJECT = projectsJson as { readonly SMOKE_TEST: string; readonly SHOWCASE: string; + readonly SHOWCASE_FIPS: string; readonly SHOWCASE_RBAC: string; readonly ANY_TEST: string; readonly SHOWCASE_K8S: string; From 24d76183b85a6d108e8dafdc975a70b13d07a575 Mon Sep 17 00:00:00 2001 From: albarbaro Date: Tue, 25 Aug 2026 11:16:14 +0200 Subject: [PATCH 2/8] fix CA trust in playwright for FIPS job --- .ci/pipelines/jobs/ocp-fips-helm.sh | 113 +++++++++++++ e2e-tests/playwright.config.ts | 4 + .../playwright/e2e/fips-compliance.spec.ts | 155 ++++++++++++++++++ 3 files changed, 272 insertions(+) create mode 100644 e2e-tests/playwright/e2e/fips-compliance.spec.ts diff --git a/.ci/pipelines/jobs/ocp-fips-helm.sh b/.ci/pipelines/jobs/ocp-fips-helm.sh index 7e91a8c804..667c9b5088 100644 --- a/.ci/pipelines/jobs/ocp-fips-helm.sh +++ b/.ci/pipelines/jobs/ocp-fips-helm.sh @@ -57,7 +57,120 @@ fips_deployment() { $(helm::get_image_params) } +# Extract OpenShift cluster CA certificate and configure Playwright to trust it +# This is required for FIPS-enabled clusters to avoid ERR_CERT_AUTHORITY_INVALID +# Returns: +# 0 - Certificate extracted and configured successfully +# 1 - Failed to extract certificate +configure_openshift_ca_for_playwright() { + log::info "Extracting OpenShift cluster CA certificate for Playwright..." + + local ca_cert_dir="${ARTIFACT_DIR:-/tmp}/cluster-certs" + local ca_cert_file="${ca_cert_dir}/openshift-ca.crt" + + mkdir -p "${ca_cert_dir}" + + # Method 1: Extract CA from the ingress operator's CA secret + # This contains the CA that signs the default ingress controller certificates + if oc get secret router-ca -n openshift-ingress-operator -o jsonpath='{.data.tls\.crt}' 2> /dev/null | base64 --decode > "${ca_cert_file}" && [[ -s "${ca_cert_file}" ]]; then + log::success "Extracted CA from router-ca secret in openshift-ingress-operator" + # Method 2: Extract from the ingress controller's serving certificate + elif oc get secret router-certs-default -n openshift-ingress -o jsonpath='{.data.tls\.crt}' 2> /dev/null | base64 --decode > "${ca_cert_file}" && [[ -s "${ca_cert_file}" ]]; then + log::success "Extracted certificate from router-certs-default secret in openshift-ingress" + # Method 3: Use openssl to fetch the certificate chain from a live route + elif command -v openssl &> /dev/null && [[ -n "${K8S_CLUSTER_ROUTER_BASE:-}" ]]; then + log::warn "Secret extraction failed, attempting to fetch certificate chain from console route..." + local console_route="console-openshift-console.${K8S_CLUSTER_ROUTER_BASE}" + + # Extract all certificates from the chain (server cert + intermediates + root CA) + # Node.js will use the appropriate CA from the chain for validation + if echo | openssl s_client -connect "${console_route}:443" -showcerts 2> /dev/null \ + | awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/' > "${ca_cert_file}" && [[ -s "${ca_cert_file}" ]]; then + log::success "Extracted certificate chain from console route" + else + log::error "Failed to extract OpenShift CA certificate using all methods" + return 1 + fi + else + log::error "Failed to extract OpenShift CA certificate - no valid method available" + return 1 + fi + + # Verify the extracted certificate(s) are valid + if command -v openssl &> /dev/null; then + # Count how many certificates are in the file + local cert_count + cert_count=$(grep -c "BEGIN CERTIFICATE" "${ca_cert_file}" 2> /dev/null || echo "0") + + if [[ "${cert_count}" -eq 0 ]]; then + log::error "No valid certificates found in extracted file" + return 1 + fi + + log::info "Found ${cert_count} certificate(s) in chain" + + # Validate the first certificate (openssl x509 reads the first cert by default) + if ! openssl x509 -in "${ca_cert_file}" -text -noout > /dev/null 2>&1; then + log::error "Certificate validation failed" + return 1 + fi + + # Log details of the first certificate for debugging + local cert_subject cert_issuer + cert_subject=$(openssl x509 -in "${ca_cert_file}" -noout -subject 2> /dev/null | sed 's/subject=//') + cert_issuer=$(openssl x509 -in "${ca_cert_file}" -noout -issuer 2> /dev/null | sed 's/issuer=//') + log::info "First certificate - Subject: ${cert_subject}" + log::info "First certificate - Issuer: ${cert_issuer}" + + log::success "Certificate validation passed" + fi + + # Export NODE_EXTRA_CA_CERTS so Node.js (and Playwright) trusts this CA + export NODE_EXTRA_CA_CERTS="${ca_cert_file}" + log::success "Set NODE_EXTRA_CA_CERTS=${NODE_EXTRA_CA_CERTS}" + + return 0 +} + +# Verify that the OpenShift cluster has FIPS mode enabled +# Returns: +# 0 - FIPS is enabled +# 1 - FIPS is not enabled or cannot be determined +verify_cluster_fips_enabled() { + log::info "Verifying OpenShift cluster FIPS configuration..." + + local install_config + install_config=$(oc get cm cluster-config-v1 -n kube-system -o jsonpath='{.data.install-config}' 2> /dev/null) + + if [[ -z "${install_config}" ]]; then + log::error "Failed to retrieve cluster install-config from kube-system/cluster-config-v1" + return 1 + fi + + if echo "${install_config}" | grep -q "fips: true"; then + log::success "✓ Cluster FIPS mode: ENABLED" + return 0 + else + log::error "✗ Cluster FIPS mode: DISABLED (expected 'fips: true' in install-config)" + log::info "Install config excerpt:" + echo "${install_config}" | grep -A2 -B2 "fips" || echo "${install_config}" | head -10 + return 1 + fi +} + run_standard_deployment_tests() { local url="https://${RELEASE_NAME}-developer-hub-${NAME_SPACE}.${K8S_CLUSTER_ROUTER_BASE}" + + # Verify cluster FIPS mode is enabled + verify_cluster_fips_enabled || { + log::error "Cluster FIPS verification failed - this job requires a FIPS-enabled cluster" + return 1 + } + + # Configure OpenShift CA certificate for Playwright + configure_openshift_ca_for_playwright || { + log::warn "Failed to configure OpenShift CA, tests may fail with certificate errors" + } + testing::check_and_test "${RELEASE_NAME}" "${NAME_SPACE}" "${PW_PROJECT_SHOWCASE_FIPS}" "${url}" } diff --git a/e2e-tests/playwright.config.ts b/e2e-tests/playwright.config.ts index fe4d0fca82..a1cbea8391 100644 --- a/e2e-tests/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -100,6 +100,7 @@ export default defineConfig({ "**/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts", "**/playwright/e2e/plugin-division-mode-schema/*.spec.ts", "**/playwright/e2e/configuration-test/config-map.spec.ts", + "**/playwright/e2e/fips-compliance.spec.ts", ], }, { @@ -138,6 +139,7 @@ export default defineConfig({ "**/playwright/e2e/auth-providers/github-happy-path.spec.ts", "**/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts", "**/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts", + "**/playwright/e2e/fips-compliance.spec.ts", ], retries: 1, }, @@ -161,6 +163,7 @@ export default defineConfig({ "**/playwright/e2e/configuration-test/config-map.spec.ts", "**/playwright/e2e/github-happy-path.spec.ts", "**/playwright/e2e/plugin-division-mode-schema/*.spec.ts", + "**/playwright/e2e/fips-compliance.spec.ts", ], }, { @@ -186,6 +189,7 @@ export default defineConfig({ "**/playwright/e2e/configuration-test/config-map.spec.ts", "**/playwright/e2e/github-happy-path.spec.ts", "**/playwright/e2e/plugin-division-mode-schema/*.spec.ts", + "**/playwright/e2e/fips-compliance.spec.ts", ], }, { diff --git a/e2e-tests/playwright/e2e/fips-compliance.spec.ts b/e2e-tests/playwright/e2e/fips-compliance.spec.ts new file mode 100644 index 0000000000..a6b368303b --- /dev/null +++ b/e2e-tests/playwright/e2e/fips-compliance.spec.ts @@ -0,0 +1,155 @@ +import { test, expect } from "@support/coverage/test"; +import * as tls from "tls"; +import * as url from "url"; + +test.describe("FIPS Compliance Validation", () => { + test.beforeAll(({}, testInfo) => { + testInfo.annotations.push({ + type: "component", + description: "fips", + }); + }); + + test("RHDH route uses FIPS-approved TLS cipher suite", async () => { + const baseUrl = process.env.BASE_URL; + if (typeof baseUrl !== "string" || baseUrl === "") { + throw new Error("BASE_URL environment variable is not set"); + } + + const parsedUrl = new url.URL(baseUrl); + const host = parsedUrl.hostname; + const port = parsedUrl.port === "" ? 443 : Math.trunc(Number(parsedUrl.port)); + + const tlsInfo = await new Promise<{ + cipher: string; + protocol: string; + authorized: boolean; + }>((resolve, reject) => { + const socket = tls.connect( + port, + host, + { + servername: host, + rejectUnauthorized: false, + }, + () => { + const cipher = socket.getCipher(); + const protocol = socket.getProtocol(); + const authorized = socket.authorized; + + socket.end(); + resolve({ + cipher: cipher.name, + protocol: protocol ?? "unknown", + authorized: authorized, + }); + }, + ); + + socket.on("error", (error) => { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + reject(new Error(`TLS connection failed: ${errorMessage}`)); + }); + + socket.setTimeout(10000, () => { + socket.destroy(); + reject(new Error("TLS connection timeout")); + }); + }); + + console.log(`[FIPS] Connected using cipher: ${tlsInfo.cipher}`); + console.log(`[FIPS] TLS protocol: ${tlsInfo.protocol}`); + console.log(`[FIPS] Certificate authorized: ${tlsInfo.authorized}`); + + expect(tlsInfo.cipher).toMatch(/AES/iu); + expect(tlsInfo.cipher).toMatch(/(GCM|CBC)/iu); + expect(tlsInfo.cipher).not.toMatch(/CHACHA20/iu); + expect(tlsInfo.cipher).not.toMatch(/3DES/iu); + expect(tlsInfo.cipher).not.toMatch(/RC4/iu); + expect(tlsInfo.protocol).toMatch(/TLSv1\.[2-3]/u); + }); + + test("RHDH route uses FIPS-approved certificate signature algorithm", async () => { + const baseUrl = process.env.BASE_URL; + if (typeof baseUrl !== "string" || baseUrl === "") { + throw new Error("BASE_URL environment variable is not set"); + } + + const parsedUrl = new url.URL(baseUrl); + const host = parsedUrl.hostname; + const port = parsedUrl.port === "" ? 443 : Math.trunc(Number(parsedUrl.port)); + + const certInfo = await new Promise<{ + sigAlg: string; + subject: string; + issuer: string; + }>((resolve, reject) => { + const socket = tls.connect( + port, + host, + { + servername: host, + rejectUnauthorized: false, + }, + () => { + const cert = socket.getPeerCertificate(); + + const certKeys = Object.keys(cert); + if (certKeys.length === 0) { + socket.end(); + reject(new Error("No certificate received from server")); + return; + } + + interface CertWithSigAlg { + sigalg?: string; + subject?: Record; + issuer?: Record; + } + + const certTyped = cert as CertWithSigAlg; + const sigAlg = typeof certTyped.sigalg === "string" ? certTyped.sigalg : "unknown"; + + const subject = + typeof certTyped.subject === "object" && certTyped.subject !== null + ? Object.entries(certTyped.subject) + .map(([k, v]) => `${k}=${String(v)}`) + .join(", ") + : "unknown"; + + const issuer = + typeof certTyped.issuer === "object" && certTyped.issuer !== null + ? Object.entries(certTyped.issuer) + .map(([k, v]) => `${k}=${String(v)}`) + .join(", ") + : "unknown"; + + socket.end(); + resolve({ + sigAlg: sigAlg, + subject: subject, + issuer: issuer, + }); + }, + ); + + socket.on("error", (error) => { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + reject(new Error(`TLS connection failed: ${errorMessage}`)); + }); + + socket.setTimeout(10000, () => { + socket.destroy(); + reject(new Error("TLS connection timeout")); + }); + }); + + console.log(`[FIPS] Certificate Subject: ${certInfo.subject}`); + console.log(`[FIPS] Certificate Issuer: ${certInfo.issuer}`); + console.log(`[FIPS] Certificate Signature Algorithm: ${certInfo.sigAlg}`); + + expect(certInfo.sigAlg.toLowerCase()).toMatch(/sha(256|384|512)/iu); + expect(certInfo.sigAlg.toLowerCase()).not.toMatch(/md5/iu); + expect(certInfo.sigAlg.toLowerCase()).not.toMatch(/sha1(?!with)/iu); + }); +}); From 847cd101eb263ccb2ce939945b85b43166945d85 Mon Sep 17 00:00:00 2001 From: albarbaro Date: Tue, 25 Aug 2026 12:50:28 +0200 Subject: [PATCH 3/8] fix CA trust in playwright for FIPS job --- .ci/pipelines/jobs/ocp-fips-helm.sh | 61 ++++++++----- .../playwright/e2e/fips-compliance.spec.ts | 89 +++++++++++-------- 2 files changed, 90 insertions(+), 60 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-fips-helm.sh b/.ci/pipelines/jobs/ocp-fips-helm.sh index 667c9b5088..4fdcfc689b 100644 --- a/.ci/pipelines/jobs/ocp-fips-helm.sh +++ b/.ci/pipelines/jobs/ocp-fips-helm.sh @@ -63,39 +63,58 @@ fips_deployment() { # 0 - Certificate extracted and configured successfully # 1 - Failed to extract certificate configure_openshift_ca_for_playwright() { - log::info "Extracting OpenShift cluster CA certificate for Playwright..." + log::info "Extracting OpenShift cluster CA certificates for Playwright..." local ca_cert_dir="${ARTIFACT_DIR:-/tmp}/cluster-certs" - local ca_cert_file="${ca_cert_dir}/openshift-ca.crt" + local ca_cert_file="${ca_cert_dir}/openshift-ca-bundle.crt" + local temp_cert_file="${ca_cert_dir}/temp-cert.crt" + local extracted_count=0 mkdir -p "${ca_cert_dir}" + rm -f "${ca_cert_file}" "${temp_cert_file}" + + # Source 1: Extract CA bundle from default-ingress-cert configmap + if oc get configmap default-ingress-cert -n openshift-config-managed -o jsonpath='{.data.ca-bundle\.crt}' 2> /dev/null > "${temp_cert_file}" && [[ -s "${temp_cert_file}" ]]; then + log::success "✓ Extracted CA bundle from default-ingress-cert configmap" + cat "${temp_cert_file}" >> "${ca_cert_file}" + extracted_count=$((extracted_count + 1)) + rm -f "${temp_cert_file}" + else + log::info "✗ Could not extract from default-ingress-cert configmap" + fi - # Method 1: Extract CA from the ingress operator's CA secret - # This contains the CA that signs the default ingress controller certificates - if oc get secret router-ca -n openshift-ingress-operator -o jsonpath='{.data.tls\.crt}' 2> /dev/null | base64 --decode > "${ca_cert_file}" && [[ -s "${ca_cert_file}" ]]; then - log::success "Extracted CA from router-ca secret in openshift-ingress-operator" - # Method 2: Extract from the ingress controller's serving certificate - elif oc get secret router-certs-default -n openshift-ingress -o jsonpath='{.data.tls\.crt}' 2> /dev/null | base64 --decode > "${ca_cert_file}" && [[ -s "${ca_cert_file}" ]]; then - log::success "Extracted certificate from router-certs-default secret in openshift-ingress" - # Method 3: Use openssl to fetch the certificate chain from a live route - elif command -v openssl &> /dev/null && [[ -n "${K8S_CLUSTER_ROUTER_BASE:-}" ]]; then - log::warn "Secret extraction failed, attempting to fetch certificate chain from console route..." - local console_route="console-openshift-console.${K8S_CLUSTER_ROUTER_BASE}" + # Source 2: Extract from router-ca secret in openshift-ingress-operator + if oc get secret router-ca -n openshift-ingress-operator -o jsonpath='{.data.tls\.crt}' 2> /dev/null | base64 --decode > "${temp_cert_file}" && [[ -s "${temp_cert_file}" ]]; then + log::success "✓ Extracted CA from router-ca secret" + cat "${temp_cert_file}" >> "${ca_cert_file}" + extracted_count=$((extracted_count + 1)) + rm -f "${temp_cert_file}" + else + log::info "✗ Could not extract from router-ca secret" + fi - # Extract all certificates from the chain (server cert + intermediates + root CA) - # Node.js will use the appropriate CA from the chain for validation + # Source 3: Extract certificate chain from console route via openssl + if command -v openssl &> /dev/null && [[ -n "${K8S_CLUSTER_ROUTER_BASE:-}" ]]; then + local console_route="console-openshift-console.${K8S_CLUSTER_ROUTER_BASE}" if echo | openssl s_client -connect "${console_route}:443" -showcerts 2> /dev/null \ - | awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/' > "${ca_cert_file}" && [[ -s "${ca_cert_file}" ]]; then - log::success "Extracted certificate chain from console route" + | awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/' > "${temp_cert_file}" && [[ -s "${temp_cert_file}" ]]; then + log::success "✓ Extracted certificate chain from console route" + cat "${temp_cert_file}" >> "${ca_cert_file}" + extracted_count=$((extracted_count + 1)) + rm -f "${temp_cert_file}" else - log::error "Failed to extract OpenShift CA certificate using all methods" - return 1 + log::info "✗ Could not extract certificate chain from console route" fi - else - log::error "Failed to extract OpenShift CA certificate - no valid method available" + fi + + # Verify we got at least one certificate source + if [[ "${extracted_count}" -eq 0 ]]; then + log::error "Failed to extract certificates from any source" return 1 fi + log::success "Successfully merged certificates from ${extracted_count} source(s)" + # Verify the extracted certificate(s) are valid if command -v openssl &> /dev/null; then # Count how many certificates are in the file diff --git a/e2e-tests/playwright/e2e/fips-compliance.spec.ts b/e2e-tests/playwright/e2e/fips-compliance.spec.ts index a6b368303b..05ca5cbf87 100644 --- a/e2e-tests/playwright/e2e/fips-compliance.spec.ts +++ b/e2e-tests/playwright/e2e/fips-compliance.spec.ts @@ -30,7 +30,7 @@ test.describe("FIPS Compliance Validation", () => { host, { servername: host, - rejectUnauthorized: false, + rejectUnauthorized: true, }, () => { const cipher = socket.getCipher(); @@ -79,20 +79,16 @@ test.describe("FIPS Compliance Validation", () => { const host = parsedUrl.hostname; const port = parsedUrl.port === "" ? 443 : Math.trunc(Number(parsedUrl.port)); - const certInfo = await new Promise<{ - sigAlg: string; - subject: string; - issuer: string; - }>((resolve, reject) => { + const certPem = await new Promise((resolve, reject) => { const socket = tls.connect( port, host, { servername: host, - rejectUnauthorized: false, + rejectUnauthorized: true, }, () => { - const cert = socket.getPeerCertificate(); + const cert = socket.getPeerCertificate(false); const certKeys = Object.keys(cert); if (certKeys.length === 0) { @@ -101,35 +97,25 @@ test.describe("FIPS Compliance Validation", () => { return; } - interface CertWithSigAlg { - sigalg?: string; - subject?: Record; - issuer?: Record; + const rawCert = cert.raw; + if (typeof rawCert !== "object" || rawCert === null) { + socket.end(); + reject(new Error("Certificate raw data not available")); + return; } - const certTyped = cert as CertWithSigAlg; - const sigAlg = typeof certTyped.sigalg === "string" ? certTyped.sigalg : "unknown"; - - const subject = - typeof certTyped.subject === "object" && certTyped.subject !== null - ? Object.entries(certTyped.subject) - .map(([k, v]) => `${k}=${String(v)}`) - .join(", ") - : "unknown"; + const pemCert = rawCert.toString("base64"); + const pemLines = pemCert.match(/.{1,64}/gu); + if (pemLines === null || pemLines.length === 0) { + socket.end(); + reject(new Error("Failed to format certificate")); + return; + } - const issuer = - typeof certTyped.issuer === "object" && certTyped.issuer !== null - ? Object.entries(certTyped.issuer) - .map(([k, v]) => `${k}=${String(v)}`) - .join(", ") - : "unknown"; + const pem = `-----BEGIN CERTIFICATE-----\n${pemLines.join("\n")}\n-----END CERTIFICATE-----`; socket.end(); - resolve({ - sigAlg: sigAlg, - subject: subject, - issuer: issuer, - }); + resolve(pem); }, ); @@ -144,12 +130,37 @@ test.describe("FIPS Compliance Validation", () => { }); }); - console.log(`[FIPS] Certificate Subject: ${certInfo.subject}`); - console.log(`[FIPS] Certificate Issuer: ${certInfo.issuer}`); - console.log(`[FIPS] Certificate Signature Algorithm: ${certInfo.sigAlg}`); - - expect(certInfo.sigAlg.toLowerCase()).toMatch(/sha(256|384|512)/iu); - expect(certInfo.sigAlg.toLowerCase()).not.toMatch(/md5/iu); - expect(certInfo.sigAlg.toLowerCase()).not.toMatch(/sha1(?!with)/iu); + const { execSync } = await import("child_process"); + + const sigAlg = execSync("openssl x509 -noout -text", { + input: certPem, + encoding: "utf-8", + }) + .split("\n") + .find((line) => line.trim().startsWith("Signature Algorithm:")) + ?.split(":")[1] + ?.trim() ?? "unknown"; + + const subject = execSync("openssl x509 -noout -subject -nameopt RFC2253", { + input: certPem, + encoding: "utf-8", + }) + .replace("subject=", "") + .trim(); + + const issuer = execSync("openssl x509 -noout -issuer -nameopt RFC2253", { + input: certPem, + encoding: "utf-8", + }) + .replace("issuer=", "") + .trim(); + + console.log(`[FIPS] Certificate Subject: ${subject}`); + console.log(`[FIPS] Certificate Issuer: ${issuer}`); + console.log(`[FIPS] Certificate Signature Algorithm: ${sigAlg}`); + + expect(sigAlg.toLowerCase()).toMatch(/sha(256|384|512)/iu); + expect(sigAlg.toLowerCase()).not.toMatch(/md5/iu); + expect(sigAlg.toLowerCase()).not.toMatch(/sha1(?!with)/iu); }); }); From c947fc1e565234926826545edfba94198b314799 Mon Sep 17 00:00:00 2001 From: albarbaro Date: Tue, 25 Aug 2026 14:57:26 +0200 Subject: [PATCH 4/8] fix CA trust in playwright for FIPS job --- .ci/pipelines/jobs/ocp-fips-helm.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.ci/pipelines/jobs/ocp-fips-helm.sh b/.ci/pipelines/jobs/ocp-fips-helm.sh index 4fdcfc689b..8fa8fd6bfc 100644 --- a/.ci/pipelines/jobs/ocp-fips-helm.sh +++ b/.ci/pipelines/jobs/ocp-fips-helm.sh @@ -191,5 +191,13 @@ run_standard_deployment_tests() { log::warn "Failed to configure OpenShift CA, tests may fail with certificate errors" } + # Add extracted CA to system trust store so Chromium browser picks it up + if [[ -n "${NODE_EXTRA_CA_CERTS}" && -f "${NODE_EXTRA_CA_CERTS}" ]]; then + log::info "Adding OpenShift CA to system trust store for Chromium..." + cp "${NODE_EXTRA_CA_CERTS}" /usr/local/share/ca-certificates/openshift-ca-bundle.crt + update-ca-certificates + log::success "System CA trust store updated" + fi + testing::check_and_test "${RELEASE_NAME}" "${NAME_SPACE}" "${PW_PROJECT_SHOWCASE_FIPS}" "${url}" } From 8357216aba61001b14732defd1a66c21c0961497 Mon Sep 17 00:00:00 2001 From: albarbaro Date: Tue, 25 Aug 2026 15:09:11 +0200 Subject: [PATCH 5/8] fix CA trust in playwright for FIPS job --- .ci/pipelines/jobs/ocp-fips-helm.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.ci/pipelines/jobs/ocp-fips-helm.sh b/.ci/pipelines/jobs/ocp-fips-helm.sh index 8fa8fd6bfc..5064f6dd7b 100644 --- a/.ci/pipelines/jobs/ocp-fips-helm.sh +++ b/.ci/pipelines/jobs/ocp-fips-helm.sh @@ -194,6 +194,10 @@ run_standard_deployment_tests() { # Add extracted CA to system trust store so Chromium browser picks it up if [[ -n "${NODE_EXTRA_CA_CERTS}" && -f "${NODE_EXTRA_CA_CERTS}" ]]; then log::info "Adding OpenShift CA to system trust store for Chromium..." + log::info "Checking permissions on /usr/local/share/ca-certificates/..." + ls -la /usr/local/share/ca-certificates/ + log::info "Current user: $(whoami)" + id cp "${NODE_EXTRA_CA_CERTS}" /usr/local/share/ca-certificates/openshift-ca-bundle.crt update-ca-certificates log::success "System CA trust store updated" From 24ba69a50aac39b46b0cba7968948ac0386b1f71 Mon Sep 17 00:00:00 2001 From: albarbaro Date: Tue, 25 Aug 2026 15:26:42 +0200 Subject: [PATCH 6/8] fix CA trust in playwright for FIPS job --- .ci/pipelines/jobs/ocp-fips-helm.sh | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-fips-helm.sh b/.ci/pipelines/jobs/ocp-fips-helm.sh index 5064f6dd7b..4fdcfc689b 100644 --- a/.ci/pipelines/jobs/ocp-fips-helm.sh +++ b/.ci/pipelines/jobs/ocp-fips-helm.sh @@ -191,17 +191,5 @@ run_standard_deployment_tests() { log::warn "Failed to configure OpenShift CA, tests may fail with certificate errors" } - # Add extracted CA to system trust store so Chromium browser picks it up - if [[ -n "${NODE_EXTRA_CA_CERTS}" && -f "${NODE_EXTRA_CA_CERTS}" ]]; then - log::info "Adding OpenShift CA to system trust store for Chromium..." - log::info "Checking permissions on /usr/local/share/ca-certificates/..." - ls -la /usr/local/share/ca-certificates/ - log::info "Current user: $(whoami)" - id - cp "${NODE_EXTRA_CA_CERTS}" /usr/local/share/ca-certificates/openshift-ca-bundle.crt - update-ca-certificates - log::success "System CA trust store updated" - fi - testing::check_and_test "${RELEASE_NAME}" "${NAME_SPACE}" "${PW_PROJECT_SHOWCASE_FIPS}" "${url}" } From 259750a9aa1357bf706bb612bf2abe54f74c9053 Mon Sep 17 00:00:00 2001 From: albarbaro Date: Thu, 27 Aug 2026 15:05:02 +0200 Subject: [PATCH 7/8] fix CA trust in playwright for FIPS job --- .ci/images/Dockerfile | 2 +- .ci/pipelines/env_variables.sh | 6 + .ci/pipelines/jobs/ocp-fips-helm.sh | 232 ++++++++++++++++------------ 3 files changed, 140 insertions(+), 100 deletions(-) diff --git a/.ci/images/Dockerfile b/.ci/images/Dockerfile index f47dcadd09..c7ca55e2a4 100644 --- a/.ci/images/Dockerfile +++ b/.ci/images/Dockerfile @@ -76,7 +76,7 @@ RUN curl -sL https://aka.ms/InstallAzureCLIDeb | bash && \ RUN echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | tee -a /etc/apt/sources.list.d/google-cloud-sdk.list && \ curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg && \ apt-get update -y && \ - apt-get install google-cloud-cli google-cloud-sdk-gke-gcloud-auth-plugin -y && \ + apt-get install google-cloud-cli google-cloud-cli-gke-gcloud-auth-plugin -y && \ rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* # Install AWS CLI (uname -m returns x86_64 on amd64, aarch64 on arm64 — matching AWS URL naming) diff --git a/.ci/pipelines/env_variables.sh b/.ci/pipelines/env_variables.sh index 2fc82420aa..0e8f3b8f17 100755 --- a/.ci/pipelines/env_variables.sh +++ b/.ci/pipelines/env_variables.sh @@ -334,4 +334,10 @@ GITHUB_APP_CLIENT_SECRET_RBAC_5=$(cat /tmp/secrets/GITHUB_APP_CLIENT_SECRET_HELM GITHUB_APP_WEBHOOK_URL_RBAC_5=$(cat /tmp/secrets/GITHUB_APP_WEBHOOK_URL_HELM_PR_3) GITHUB_APP_WEBHOOK_SECRET_RBAC_5=$(cat /tmp/secrets/GITHUB_APP_WEBHOOK_SECRET_HELM_PR_3) +# FIPS Custom CA Certificate (from Vault) +# Base64-encoded PEM certificate and private key for custom ingress certificates +# Used only by the FIPS job to configure custom CA-signed certificates for cluster ingress +FIPS_ROOT_CA_CERT=$(cat /tmp/secrets/FIPS_ROOT_CA_CERT) +FIPS_ROOT_CA_KEY=$(cat /tmp/secrets/FIPS_ROOT_CA_KEY) + set +a # Stop automatically exporting variables diff --git a/.ci/pipelines/jobs/ocp-fips-helm.sh b/.ci/pipelines/jobs/ocp-fips-helm.sh index 4fdcfc689b..d158298fc7 100644 --- a/.ci/pipelines/jobs/ocp-fips-helm.sh +++ b/.ci/pipelines/jobs/ocp-fips-helm.sh @@ -57,100 +57,6 @@ fips_deployment() { $(helm::get_image_params) } -# Extract OpenShift cluster CA certificate and configure Playwright to trust it -# This is required for FIPS-enabled clusters to avoid ERR_CERT_AUTHORITY_INVALID -# Returns: -# 0 - Certificate extracted and configured successfully -# 1 - Failed to extract certificate -configure_openshift_ca_for_playwright() { - log::info "Extracting OpenShift cluster CA certificates for Playwright..." - - local ca_cert_dir="${ARTIFACT_DIR:-/tmp}/cluster-certs" - local ca_cert_file="${ca_cert_dir}/openshift-ca-bundle.crt" - local temp_cert_file="${ca_cert_dir}/temp-cert.crt" - local extracted_count=0 - - mkdir -p "${ca_cert_dir}" - rm -f "${ca_cert_file}" "${temp_cert_file}" - - # Source 1: Extract CA bundle from default-ingress-cert configmap - if oc get configmap default-ingress-cert -n openshift-config-managed -o jsonpath='{.data.ca-bundle\.crt}' 2> /dev/null > "${temp_cert_file}" && [[ -s "${temp_cert_file}" ]]; then - log::success "✓ Extracted CA bundle from default-ingress-cert configmap" - cat "${temp_cert_file}" >> "${ca_cert_file}" - extracted_count=$((extracted_count + 1)) - rm -f "${temp_cert_file}" - else - log::info "✗ Could not extract from default-ingress-cert configmap" - fi - - # Source 2: Extract from router-ca secret in openshift-ingress-operator - if oc get secret router-ca -n openshift-ingress-operator -o jsonpath='{.data.tls\.crt}' 2> /dev/null | base64 --decode > "${temp_cert_file}" && [[ -s "${temp_cert_file}" ]]; then - log::success "✓ Extracted CA from router-ca secret" - cat "${temp_cert_file}" >> "${ca_cert_file}" - extracted_count=$((extracted_count + 1)) - rm -f "${temp_cert_file}" - else - log::info "✗ Could not extract from router-ca secret" - fi - - # Source 3: Extract certificate chain from console route via openssl - if command -v openssl &> /dev/null && [[ -n "${K8S_CLUSTER_ROUTER_BASE:-}" ]]; then - local console_route="console-openshift-console.${K8S_CLUSTER_ROUTER_BASE}" - if echo | openssl s_client -connect "${console_route}:443" -showcerts 2> /dev/null \ - | awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/' > "${temp_cert_file}" && [[ -s "${temp_cert_file}" ]]; then - log::success "✓ Extracted certificate chain from console route" - cat "${temp_cert_file}" >> "${ca_cert_file}" - extracted_count=$((extracted_count + 1)) - rm -f "${temp_cert_file}" - else - log::info "✗ Could not extract certificate chain from console route" - fi - fi - - # Verify we got at least one certificate source - if [[ "${extracted_count}" -eq 0 ]]; then - log::error "Failed to extract certificates from any source" - return 1 - fi - - log::success "Successfully merged certificates from ${extracted_count} source(s)" - - # Verify the extracted certificate(s) are valid - if command -v openssl &> /dev/null; then - # Count how many certificates are in the file - local cert_count - cert_count=$(grep -c "BEGIN CERTIFICATE" "${ca_cert_file}" 2> /dev/null || echo "0") - - if [[ "${cert_count}" -eq 0 ]]; then - log::error "No valid certificates found in extracted file" - return 1 - fi - - log::info "Found ${cert_count} certificate(s) in chain" - - # Validate the first certificate (openssl x509 reads the first cert by default) - if ! openssl x509 -in "${ca_cert_file}" -text -noout > /dev/null 2>&1; then - log::error "Certificate validation failed" - return 1 - fi - - # Log details of the first certificate for debugging - local cert_subject cert_issuer - cert_subject=$(openssl x509 -in "${ca_cert_file}" -noout -subject 2> /dev/null | sed 's/subject=//') - cert_issuer=$(openssl x509 -in "${ca_cert_file}" -noout -issuer 2> /dev/null | sed 's/issuer=//') - log::info "First certificate - Subject: ${cert_subject}" - log::info "First certificate - Issuer: ${cert_issuer}" - - log::success "Certificate validation passed" - fi - - # Export NODE_EXTRA_CA_CERTS so Node.js (and Playwright) trusts this CA - export NODE_EXTRA_CA_CERTS="${ca_cert_file}" - log::success "Set NODE_EXTRA_CA_CERTS=${NODE_EXTRA_CA_CERTS}" - - return 0 -} - # Verify that the OpenShift cluster has FIPS mode enabled # Returns: # 0 - FIPS is enabled @@ -186,10 +92,138 @@ run_standard_deployment_tests() { return 1 } - # Configure OpenShift CA certificate for Playwright - configure_openshift_ca_for_playwright || { - log::warn "Failed to configure OpenShift CA, tests may fail with certificate errors" - } - testing::check_and_test "${RELEASE_NAME}" "${NAME_SPACE}" "${PW_PROJECT_SHOWCASE_FIPS}" "${url}" } + +# Configure custom CA certificate for OpenShift Ingress Controller +# This function generates a wildcard certificate signed by a custom root CA +# and patches the default IngressController to use it. +# +# Required environment variables: +# FIPS_ROOT_CA_CERT - Base64-encoded root CA certificate (PEM format) +# FIPS_ROOT_CA_KEY - Base64-encoded root CA private key (PEM format) +# K8S_CLUSTER_ROUTER_BASE - Cluster router base domain (e.g., apps.example.com) +# +# Returns: +# 0 - Success +# 1 - Failure (missing vars, cert generation failed, or patch failed) +fips_configure_custom_ca_ingress() { + log::info "Configuring custom CA certificate for OpenShift Ingress..." + + # Verify required environment variables + if [[ -z "${FIPS_ROOT_CA_CERT:-}" ]] || [[ -z "${FIPS_ROOT_CA_KEY:-}" ]]; then + log::warning "FIPS_ROOT_CA_CERT or FIPS_ROOT_CA_KEY not set - skipping custom CA configuration" + return 0 + fi + + if [[ -z "${K8S_CLUSTER_ROUTER_BASE:-}" ]]; then + log::error "K8S_CLUSTER_ROUTER_BASE is not set - cannot determine cluster domain" + return 1 + fi + + local wildcard_domain="*.${K8S_CLUSTER_ROUTER_BASE}" + local secret_name="custom-certs-default" + local ingress_namespace="openshift-ingress" + local tmpdir + tmpdir=$(mktemp -d) + + # Ensure cleanup on exit + trap 'rm -rf "${tmpdir}"' EXIT + + log::info "Generating wildcard certificate for domain: ${wildcard_domain}" + + # Write CA cert and key to temporary files + echo "${FIPS_ROOT_CA_CERT}" | base64 -d > "${tmpdir}/rootCA.crt" + echo "${FIPS_ROOT_CA_KEY}" | base64 -d > "${tmpdir}/rootCA.key" + + # Generate ECDSA P-256 key for wildcard certificate (FIPS-compliant) + openssl ecparam -name prime256v1 -genkey -noout -out "${tmpdir}/wildcard.key" + + # Generate CSR + openssl req -new -key "${tmpdir}/wildcard.key" \ + -out "${tmpdir}/wildcard.csr" \ + -subj "/O=CI-FIPS-Testing/CN=FIPS CI Ingress" + + # Create extensions file for v3 certificate + cat > "${tmpdir}/wildcard_ext.cnf" << EOF +[ v3_req ] +basicConstraints = CA:FALSE +keyUsage = critical, digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth +subjectKeyIdentifier = hash +authorityKeyIdentifier = issuer +subjectAltName = @alt_names + +[ alt_names ] +DNS.1 = ${wildcard_domain} +DNS.2 = ${K8S_CLUSTER_ROUTER_BASE} +EOF + + # Sign the CSR with the Root CA + openssl x509 -req -in "${tmpdir}/wildcard.csr" \ + -CA "${tmpdir}/rootCA.crt" -CAkey "${tmpdir}/rootCA.key" -CAcreateserial \ + -out "${tmpdir}/wildcard.crt" -days 30 -sha256 \ + -extfile "${tmpdir}/wildcard_ext.cnf" -extensions v3_req + + if [[ ! -f "${tmpdir}/wildcard.crt" ]]; then + log::error "Failed to generate wildcard certificate" + return 1 + fi + + log::success "✓ Wildcard certificate generated successfully" + + # Verify the certificate + local cert_subject + cert_subject=$(openssl x509 -in "${tmpdir}/wildcard.crt" -noout -subject) + log::info "Certificate subject: ${cert_subject}" + + # Create TLS secret in openshift-ingress namespace + log::info "Creating TLS secret '${secret_name}' in namespace '${ingress_namespace}'" + + # Delete existing secret if it exists + oc delete secret "${secret_name}" -n "${ingress_namespace}" --ignore-not-found=true + + # Create new secret + oc create secret tls "${secret_name}" \ + -n "${ingress_namespace}" \ + --cert="${tmpdir}/wildcard.crt" \ + --key="${tmpdir}/wildcard.key" + + if [[ $? -ne 0 ]]; then + log::error "Failed to create TLS secret in ${ingress_namespace}" + return 1 + fi + + log::success "✓ TLS secret '${secret_name}' created in namespace '${ingress_namespace}'" + + # Clean up temporary files immediately + rm -rf "${tmpdir}" + trap - EXIT + + # Patch the default IngressController to use the custom certificate + log::info "Patching default IngressController to use custom certificate..." + + oc patch ingresscontroller.operator default \ + -n openshift-ingress-operator \ + --type=merge \ + -p "{\"spec\":{\"defaultCertificate\":{\"name\":\"${secret_name}\"}}}" + + if [[ $? -ne 0 ]]; then + log::error "Failed to patch IngressController" + return 1 + fi + + log::success "✓ IngressController patched successfully" + + # Wait for the router deployment to roll out with new certificates + log::info "Waiting for router pods to restart with new certificates..." + + if ! oc rollout status deployment/router-default -n "${ingress_namespace}" --timeout=5m; then + log::warning "Router rollout did not complete within timeout - continuing anyway" + else + log::success "✓ Router pods restarted successfully" + fi + + log::success "Custom CA ingress configuration completed successfully" + return 0 +} From 51cd3884d7b5f70d88b7b3dd557c598718d2adda Mon Sep 17 00:00:00 2001 From: albarbaro Date: Thu, 27 Aug 2026 15:06:53 +0200 Subject: [PATCH 8/8] fix CA trust in playwright for FIPS job --- .ci/images/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/images/Dockerfile b/.ci/images/Dockerfile index c7ca55e2a4..f47dcadd09 100644 --- a/.ci/images/Dockerfile +++ b/.ci/images/Dockerfile @@ -76,7 +76,7 @@ RUN curl -sL https://aka.ms/InstallAzureCLIDeb | bash && \ RUN echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | tee -a /etc/apt/sources.list.d/google-cloud-sdk.list && \ curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg && \ apt-get update -y && \ - apt-get install google-cloud-cli google-cloud-cli-gke-gcloud-auth-plugin -y && \ + apt-get install google-cloud-cli google-cloud-sdk-gke-gcloud-auth-plugin -y && \ rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* # Install AWS CLI (uname -m returns x86_64 on amd64, aarch64 on arm64 — matching AWS URL naming)