diff --git a/.ci/pipelines/jobs/ocp-disconnected-helm.sh b/.ci/pipelines/jobs/ocp-disconnected-helm.sh new file mode 100644 index 0000000000..781ccfd979 --- /dev/null +++ b/.ci/pipelines/jobs/ocp-disconnected-helm.sh @@ -0,0 +1,231 @@ +#!/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 +# shellcheck source=.ci/pipelines/lib/disconnected.sh +source "$DIR"/lib/disconnected.sh + +export INSTALL_METHOD="helm" + +handle_ocp_disconnected_helm() { + export NAME_SPACE="${NAME_SPACE:-showcase-ci-disconnected}" + + disconnected::require_env + disconnected::setup_auth + + 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 + + log::section "Chart Resolution" + + local is_ga="false" + if [[ "${IMAGE_REGISTRY}" == "registry.redhat.io" ]]; then + is_ga="true" + fi + + if [[ "${is_ga}" == "true" ]]; then + helm repo add openshift-helm-charts https://charts.openshift.io 2> /dev/null || true + helm repo update openshift-helm-charts + log::info "Pulling GA chart from charts.openshift.io (version: ${RELEASE_VERSION})" + helm pull openshift-helm-charts/redhat-developer-hub \ + --version "${RELEASE_VERSION}" \ + -d "${DISCONNECTED_TMPDIR}" || { + log::error "Failed to pull chart from charts.openshift.io" + return 1 + } + else + log::info "Pulling CI chart from ${HELM_CHART_URL} (version: ${CHART_VERSION})" + helm pull "${HELM_CHART_URL}" --version "${CHART_VERSION}" \ + -d "${DISCONNECTED_TMPDIR}" || { + log::error "Failed to pull chart from ${HELM_CHART_URL}" + return 1 + } + fi + + CHART_LOCAL_TGZ=$(find "${DISCONNECTED_TMPDIR}" -maxdepth 1 -name '*.tgz' | head -1) + export CHART_LOCAL_TGZ + + if [[ -z "${CHART_LOCAL_TGZ}" ]]; then + log::error "No chart .tgz found in ${DISCONNECTED_TMPDIR}" + return 1 + fi + log::success "Chart pulled: ${CHART_LOCAL_TGZ}" + + # Resolve PostgreSQL image from chart values + local helm_values + helm_values=$(helm show values "${CHART_LOCAL_TGZ}" 2> /dev/null || true) + + export PG_REGISTRY PG_REPO PG_TAG PG_SEPARATOR + PG_REGISTRY=$(echo "${helm_values}" | yq '.upstream.postgresql.image.registry' || true) + PG_REPO=$(echo "${helm_values}" | yq '.upstream.postgresql.image.repository' || true) + PG_TAG=$(echo "${helm_values}" | yq '.upstream.postgresql.image.tag' || true) + PG_REGISTRY="${PG_REGISTRY:-registry.redhat.io}" + PG_REPO="${PG_REPO:-rhel9/postgresql-15}" + PG_TAG="${PG_TAG:-latest}" + + # The chart encodes digest refs as repository: "repo@sha256" + tag: "". + # Normalize: extract the digest qualifier into PG_SEPARATOR so that: + # - PG_REPO is always a clean path (usable in IDMS source/mirror fields) + # - Full ref is ${PG_REGISTRY}/${PG_REPO}${PG_SEPARATOR}${PG_TAG} + PG_SEPARATOR=":" + if [[ "${PG_REPO}" == *"@"* ]]; then + PG_SEPARATOR="@${PG_REPO##*@}:" # e.g., "@sha256:" + PG_REPO="${PG_REPO%@*}" # e.g., "rhel9/postgresql-15" + fi + + log::info "PostgreSQL image from chart: ${PG_REGISTRY}/${PG_REPO}${PG_SEPARATOR}${PG_TAG}" + + echo "${helm_values}" > "${ARTIFACT_DIR}/disconnected-helm-chart-values.yaml" 2> /dev/null || true + + log::section "Image Mirroring" + + local imageset_config="${DISCONNECTED_TMPDIR}/imageset-config.yaml" + disconnected::build_imageset_config "${imageset_config}" || { + log::error "Failed to build ImageSetConfiguration" + return 1 + } + + local workspace="${DISCONNECTED_TMPDIR}/oc-mirror-workspace" + disconnected::run_oc_mirror "${imageset_config}" "${workspace}" || { + log::error "oc-mirror failed — aborting" + return 1 + } + + log::section "Cluster Resources" + + disconnected::patch_idms "${OC_MIRROR_IDMS_FILE}" + + oc apply -f "${OC_MIRROR_IDMS_FILE}" || { + log::error "Failed to apply IDMS — aborting" + return 1 + } + log::success "ImageDigestMirrorSet applied" + + if [[ -n "${OC_MIRROR_ITMS_FILE:-}" ]]; then + oc apply -f "${OC_MIRROR_ITMS_FILE}" || { + log::error "Failed to apply ITMS — aborting" + return 1 + } + log::success "ImageTagMirrorSet applied" + fi + + log::section "Plugin Mirroring" + + disconnected::fetch_script "mirror-plugins.sh" "${DISCONNECTED_TMPDIR}/mirror-plugins.sh" || { + log::error "Failed to fetch mirror-plugins.sh — aborting" + return 1 + } + + local plugin_index="oci://registry.access.redhat.com/rhdh/plugin-catalog-index:${RELEASE_VERSION}" + if [[ -n "${CATALOG_INDEX_IMAGE:-}" ]]; then + plugin_index="oci://${CATALOG_INDEX_IMAGE}" + fi + + bash "${DISCONNECTED_TMPDIR}/mirror-plugins.sh" \ + --plugin-index "${plugin_index}" \ + --to-registry "${MIRROR_REGISTRY_URL}" || { + log::error "mirror-plugins.sh failed — aborting" + return 1 + } + + log::section "Namespace and Secrets" + + namespace::configure "${NAME_SPACE}" + + envsubst < "${DIR}/resources/disconnected/plugin-mirror-configmap.yaml" \ + | oc apply -n "${NAME_SPACE}" -f - || { + log::error "Failed to create registries.conf ConfigMap — aborting" + return 1 + } + log::success "ConfigMap rhdh-plugin-mirror-conf created in ${NAME_SPACE}" + + envsubst < "${DIR}/resources/disconnected/plugin-mirror-configmap.yaml" \ + > "${ARTIFACT_DIR}/disconnected-plugin-mirror-configmap.yaml" 2> /dev/null || true + + # Mirror registry CA — mounted at /etc/containers/certs.d//ca.crt + # inside the init container so skopeo trusts the mirror when IDMS redirects + # quay.io/registry.redhat.io pulls. Uses the standard container-tools + # per-registry CA mechanism; no system trust store replacement needed. + oc create configmap mirror-registry-ca \ + --from-file="ca.crt=${MIRROR_REGISTRY_CA}" \ + -n "${NAME_SPACE}" \ + --dry-run=client -o yaml | oc apply -f - || { + log::error "Failed to create mirror-registry-ca ConfigMap — aborting" + return 1 + } + log::success "ConfigMap mirror-registry-ca created in ${NAME_SPACE}" + + # Registry auth for the init container — the chart mounts + # ${RELEASE_NAME}-dynamic-plugins-registry-auth at + # /opt/app-root/src/.config/containers (containers auth.json path). + oc create secret generic "${RELEASE_NAME}-dynamic-plugins-registry-auth" \ + --from-file="auth.json=${MIRROR_REGISTRY_PULL_SECRET}" \ + -n "${NAME_SPACE}" \ + --dry-run=client -o yaml | oc apply -f - || { + log::error "Failed to create registry auth secret — aborting" + return 1 + } + log::success "Secret ${RELEASE_NAME}-dynamic-plugins-registry-auth created in ${NAME_SPACE}" + + log::section "Helm Deployment" + + # Prefer the chart from oc-mirror workspace, fall back to the pulled tgz + local chart_install_path + chart_install_path="${OC_MIRROR_CHART_PATH:-${CHART_LOCAL_TGZ}}" + log::info "Installing chart from: ${chart_install_path}" + + local helm_set_flags=( + --set global.clusterRouterBase="${K8S_CLUSTER_ROUTER_BASE}" + --set upstream.backstage.image.registry="${MIRROR_REGISTRY_URL}" + --set upstream.backstage.image.repository="${IMAGE_REPO}" + --set upstream.backstage.image.tag="${TAG_NAME}" + --set upstream.postgresql.image.registry="${MIRROR_REGISTRY_URL}" + ) + + if [[ -n "${CATALOG_INDEX_IMAGE:-}" ]]; then + helm_set_flags+=( + --set global.catalogIndex.image.registry="${MIRROR_REGISTRY_URL}" + --set global.catalogIndex.image.repository="${CATALOG_INDEX_REPO}" + --set global.catalogIndex.image.tag="${CATALOG_INDEX_TAG}" + ) + fi + + # Post-renderer appends disconnected volumes (registries.conf, mirror CA) + # to the rendered Deployment, avoiding the Helm "array clobber" pitfall. + local post_renderer="${DIR}/resources/disconnected/helm-post-renderer.sh" + + helm upgrade -i "${RELEASE_NAME}" -n "${NAME_SPACE}" \ + "${chart_install_path}" \ + -f "${DIR}/value_files/values_disconnected-smoke.yaml" \ + --post-renderer "${post_renderer}" \ + --post-renderer-args "${MIRROR_REGISTRY_URL}" \ + "${helm_set_flags[@]}" || { + log::error "Helm deployment failed" + return 1 + } + + log::success "RHDH deployed via Helm with mirrored images" + + printf '%s\n' "${helm_set_flags[@]}" > "${ARTIFACT_DIR}/disconnected-helm-set-flags.txt" 2> /dev/null || true + + log::section "Smoke Test" + + if [[ -n "${HTTPS_PROXY:-}" ]]; then + log::info "HTTPS_PROXY is set (Playwright will use it): ${HTTPS_PROXY%%@*}@***" + fi + + local url="https://${RELEASE_NAME}-developer-hub-${NAME_SPACE}.${K8S_CLUSTER_ROUTER_BASE}" + testing::check_and_test "${RELEASE_NAME}" "${NAME_SPACE}" "${PW_PROJECT_SMOKE_TEST}" "${url}" + + log::success "Disconnected Helm smoke test completed" +} diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh new file mode 100644 index 0000000000..4143630662 --- /dev/null +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -0,0 +1,145 @@ +#!/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/install-methods/operator.sh +source "$DIR"/install-methods/operator.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 +# shellcheck source=.ci/pipelines/lib/disconnected.sh +source "$DIR"/lib/disconnected.sh + +export INSTALL_METHOD="operator" + +handle_ocp_disconnected_operator() { + export NAME_SPACE="${NAME_SPACE:-showcase-disconnected}" + + disconnected::require_env + disconnected::setup_auth + + 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 + + # Uses prepare-restricted-environment.sh from rhdh-operator, which handles + # mirroring operator/operand images and installing the operator CatalogSource. + log::section "Operator Mirroring and Installation" + + disconnected::fetch_script "prepare-restricted-environment.sh" "${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" \ + || { + log::error "Failed to fetch prepare-restricted-environment.sh — aborting" + return 1 + } + + local prepare_args=( + --to-registry "${MIRROR_REGISTRY_URL}" + --filter-versions "${RELEASE_VERSION}" + ) + if [[ -n "${CATALOG_INDEX_IMAGE:-}" ]]; then + prepare_args=( + --to-registry "${MIRROR_REGISTRY_URL}" + --index-image "${CATALOG_INDEX_IMAGE}" + --ci-index true + --filter-versions "${RELEASE_VERSION}" + ) + fi + + # The CI pod runs with nested_podman: true (hostUsers: false), placing it + # inside a Linux user namespace. podman's rootless setup calls newuidmap to + # create a nested user namespace, which fails with: + # newuidmap: open of uid_map failed: Permission denied + # Fix: + # _CONTAINERS_USERNS_CONFIGURED=1 — skip newuidmap (userns already set up) + # BUILDAH_ISOLATION=chroot — chroot instead of user namespace for builds + # storage driver=vfs — avoid fuse-overlayfs which may need userns ops + export _CONTAINERS_USERNS_CONFIGURED=1 + export BUILDAH_ISOLATION=chroot + mkdir -p "${HOME}/.config/containers" + printf '[storage]\ndriver = "vfs"\n' > "${HOME}/.config/containers/storage.conf" + + log::info "Podman environment: uid=$(id -u), BUILDAH_ISOLATION=${BUILDAH_ISOLATION}" + log::info "Storage config: $(cat "${HOME}/.config/containers/storage.conf" | tr '\n' ' ')" + log::info "subuid: $(cat /etc/subuid 2> /dev/null || echo 'not found')" + + bash "${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" "${prepare_args[@]}" \ + || { + log::error "prepare-restricted-environment.sh failed — aborting" + return 1 + } + log::success "Operator installed via prepare-restricted-environment.sh" + + k8s_wait::crd "backstages.rhdh.redhat.com" 300 10 || { + log::error "Backstage CRD not available after operator installation" + return 1 + } + + log::section "Plugin Mirroring" + + disconnected::fetch_script "mirror-plugins.sh" "${DISCONNECTED_TMPDIR}/mirror-plugins.sh" \ + || { + log::error "Failed to fetch mirror-plugins.sh — aborting" + return 1 + } + + local plugin_index="oci://registry.access.redhat.com/rhdh/plugin-catalog-index:${RELEASE_VERSION}" + if [[ -n "${CATALOG_INDEX_IMAGE:-}" ]]; then + plugin_index="oci://${CATALOG_INDEX_IMAGE}" + fi + + bash "${DISCONNECTED_TMPDIR}/mirror-plugins.sh" \ + --plugin-index "${plugin_index}" \ + --to-registry "${MIRROR_REGISTRY_URL}" || { + log::error "mirror-plugins.sh failed — aborting" + return 1 + } + + log::section "Namespace and Secrets" + + namespace::configure "${NAME_SPACE}" + + envsubst < "${DIR}/resources/disconnected/plugin-mirror-configmap.yaml" \ + | oc apply -n "${NAME_SPACE}" -f - || { + log::error "Failed to create registries.conf ConfigMap — aborting" + return 1 + } + log::success "ConfigMap rhdh-plugin-mirror-conf created in ${NAME_SPACE}" + + envsubst < "${DIR}/resources/disconnected/plugin-mirror-configmap.yaml" \ + > "${ARTIFACT_DIR}/disconnected-plugin-mirror-configmap.yaml" 2> /dev/null || true + + log::section "Backstage CR Deployment" + + local rendered_cr + rendered_cr=$(envsubst < "${DIR}/resources/rhdh-operator/rhdh-start.yaml") + rendered_cr=$(echo "$rendered_cr" | yq eval \ + '.spec.application.extraFiles.configMaps = [ + { + "name": "rhdh-plugin-mirror-conf", + "key": "rhdh-registries.conf", + "mountPath": "/etc/containers/registries.conf.d", + "containers": ["install-dynamic-plugins"] + } + ]' -) + + local cr_temp="${DISCONNECTED_TMPDIR}/backstage-cr-disconnected.yaml" + echo "$rendered_cr" > "${cr_temp}" + + cp "${cr_temp}" "${ARTIFACT_DIR}/disconnected-backstage-cr.yaml" 2> /dev/null || true + + deploy_rhdh_operator "${NAME_SPACE}" "${cr_temp}" + log::success "Backstage CR deployed in ${NAME_SPACE}" + + log::section "Smoke Test" + + local url="https://backstage-${RELEASE_NAME}-${NAME_SPACE}.${K8S_CLUSTER_ROUTER_BASE}" + testing::check_and_test "${RELEASE_NAME}" "${NAME_SPACE}" "${PW_PROJECT_SMOKE_TEST}" "${url}" + + log::success "Disconnected Operator smoke test completed" +} diff --git a/.ci/pipelines/lib/disconnected.sh b/.ci/pipelines/lib/disconnected.sh new file mode 100644 index 0000000000..fa5171eee3 --- /dev/null +++ b/.ci/pipelines/lib/disconnected.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash + +# Shared utility functions for disconnected CI pipeline handlers. +# Provides environment validation, oc-mirror-based image mirroring, +# auth setup, and external script fetching. +# +# Dependencies: lib/log.sh, lib/common.sh +# Consumers: jobs/ocp-disconnected-helm.sh, jobs/ocp-disconnected-operator.sh + +# Prevent re-sourcing +if [[ -n "${DISCONNECTED_LIB_SOURCED:-}" ]]; then + return 0 +fi +readonly DISCONNECTED_LIB_SOURCED=1 + +# shellcheck source=.ci/pipelines/lib/log.sh +source "${DIR}/lib/log.sh" +# shellcheck source=.ci/pipelines/lib/common.sh +source "${DIR}/lib/common.sh" + +# Create a dedicated temp directory for disconnected CI artifacts. +DISCONNECTED_TMPDIR=$(mktemp -d) +export DISCONNECTED_TMPDIR + +# Validate that all required disconnected environment variables are set. +# These are exported by the step-registry commands.sh before calling +# openshift-ci-tests.sh. +disconnected::require_env() { + if [[ "${DISCONNECTED:-}" != "true" ]]; then + log::error "DISCONNECTED is not set to 'true'. This handler requires a disconnected environment." + log::error "Ensure the step-registry commands.sh has run before this handler." + return 1 + fi + + common::require_vars \ + MIRROR_REGISTRY_URL \ + MIRROR_REGISTRY_PULL_SECRET \ + MIRROR_REGISTRY_CA +} + +# Configure container-tools authentication for skopeo, oc-mirror, and +# mirror-plugins.sh. Places the combined pull secret (which contains +# credentials for both source registries and the mirror registry) in +# the standard locations expected by these tools. +disconnected::setup_auth() { + export HOME="${HOME:-/tmp/home}" + export XDG_RUNTIME_DIR="${HOME}/run" + mkdir -p "${XDG_RUNTIME_DIR}/containers" + + # oc-mirror and skopeo read auth from ${XDG_RUNTIME_DIR}/containers/auth.json + cp "${MIRROR_REGISTRY_PULL_SECRET}" "${XDG_RUNTIME_DIR}/containers/auth.json" + + # REGISTRY_AUTH_FILE is respected by skopeo as an explicit override + export REGISTRY_AUTH_FILE="${MIRROR_REGISTRY_PULL_SECRET}" + + # oc-mirror requires this to be unset + unset REGISTRY_AUTH_PREFERENCE + + log::info "Container auth configured from ${MIRROR_REGISTRY_PULL_SECRET}" +} + +# Build an ImageSetConfiguration for oc-mirror. +# The configuration is dynamically generated based on IMAGE_REGISTRY: +# - registry.redhat.io (GA): uses helm.local with chart pulled from charts.openshift.io +# - anything else (CI/upstream): uses helm.local with chart pulled from OCI +# Args: +# $1 - output_path: Path to write the ImageSetConfiguration YAML +disconnected::build_imageset_config() { + local output_path=$1 + + # Start with the base config + cat > "${output_path}" << EOF +kind: ImageSetConfiguration +apiVersion: mirror.openshift.io/v2alpha1 +mirror: + helm: + local: + - name: redhat-developer-hub + path: ${CHART_LOCAL_TGZ} +EOF + + # Add additional images that need mirroring beyond what the chart references. + # The chart's default images are discovered automatically by oc-mirror. + local additional_images=() + + # When the hub image is overridden (different from chart defaults), add it + # so oc-mirror mirrors the actual image we'll deploy with. + if [[ "${IMAGE_REGISTRY}" != "registry.redhat.io" ]]; then + # CI/upstream: chart defaults to quay.io/rhdh/rhdh-hub-rhel9@sha256:..., + # but we may deploy with a different tag (e.g., rhdh-community/rhdh:next) + additional_images+=("${IMAGE_REGISTRY}/${IMAGE_REPO}:${TAG_NAME}") + fi + + # PG image: CI charts may use quay.io/fedora/postgresql-15 instead of + # registry.redhat.io/rhel9/postgresql-15. Add it if not from registry.redhat.io. + # PG_SEPARATOR accounts for digest (@sha256:) vs tag (:) encoding. + if [[ "${PG_REGISTRY:-registry.redhat.io}" != "registry.redhat.io" ]]; then + additional_images+=("${PG_REGISTRY}/${PG_REPO}${PG_SEPARATOR}${PG_TAG}") + fi + + if [[ ${#additional_images[@]} -gt 0 ]]; then + { + echo " additionalImages:" + for img in "${additional_images[@]}"; do + echo " - name: ${img}" + done + } >> "${output_path}" + fi + + log::info "ImageSetConfiguration written to ${output_path}" + log::debug "$(cat "${output_path}")" + + cp "${output_path}" "${ARTIFACT_DIR}/disconnected-imageset-config.yaml" 2> /dev/null || true +} + +# Run oc-mirror to mirror images to the disconnected mirror registry. +# Sets OC_MIRROR_IDMS_FILE, OC_MIRROR_ITMS_FILE, and OC_MIRROR_CHART_PATH. +# Args: +# $1 - imageset_config: Path to the ImageSetConfiguration YAML +# $2 - workspace_dir: Path to the oc-mirror workspace directory +disconnected::run_oc_mirror() { + local imageset_config=$1 + local workspace_dir=$2 + + mkdir -p "${workspace_dir}" + + # oc-mirror panics when REGISTRY_AUTH_FILE is set because the + # distribution/distribution library interprets it as a storage driver + # config, not an auth file path. oc-mirror reads auth exclusively from + # ${XDG_RUNTIME_DIR}/containers/auth.json (set up by setup_auth). + local saved_registry_auth_file="${REGISTRY_AUTH_FILE:-}" + unset REGISTRY_AUTH_FILE + + log::info "Running oc-mirror --v2 → ${MIRROR_REGISTRY_URL}" + if ! oc-mirror \ + -c "${imageset_config}" \ + "docker://${MIRROR_REGISTRY_URL}" \ + --dest-tls-verify=false \ + --v2 \ + --workspace "file://${workspace_dir}"; then + # Restore before returning + [[ -n "${saved_registry_auth_file}" ]] && export REGISTRY_AUTH_FILE="${saved_registry_auth_file}" + log::error "oc-mirror failed" + return 1 + fi + + # Restore REGISTRY_AUTH_FILE for subsequent skopeo/mirror-plugins.sh calls + if [[ -n "${saved_registry_auth_file}" ]]; then + export REGISTRY_AUTH_FILE="${saved_registry_auth_file}" + fi + + local result_dir="${workspace_dir}/working-dir" + + # IDMS (required) + OC_MIRROR_IDMS_FILE="${result_dir}/cluster-resources/idms-oc-mirror.yaml" + if [[ ! -s "${OC_MIRROR_IDMS_FILE}" ]]; then + log::error "oc-mirror did not generate IDMS at ${OC_MIRROR_IDMS_FILE}" + return 1 + fi + export OC_MIRROR_IDMS_FILE + + # ITMS (optional) + OC_MIRROR_ITMS_FILE="${result_dir}/cluster-resources/itms-oc-mirror.yaml" + if [[ ! -s "${OC_MIRROR_ITMS_FILE}" ]]; then + OC_MIRROR_ITMS_FILE="" + fi + export OC_MIRROR_ITMS_FILE + + # Chart path (in the workspace) + OC_MIRROR_CHART_PATH=$(find "${result_dir}/helm/charts" -name '*.tgz' 2> /dev/null | head -1) + export OC_MIRROR_CHART_PATH + + log::success "oc-mirror completed successfully" + log::info "IDMS: ${OC_MIRROR_IDMS_FILE}" + [[ -n "${OC_MIRROR_ITMS_FILE}" ]] && log::info "ITMS: ${OC_MIRROR_ITMS_FILE}" + [[ -n "${OC_MIRROR_CHART_PATH}" ]] && log::info "Chart: ${OC_MIRROR_CHART_PATH}" + + # Save artifacts for debugging + cp "${OC_MIRROR_IDMS_FILE}" "${ARTIFACT_DIR}/disconnected-idms-generated.yaml" 2> /dev/null || true + [[ -n "${OC_MIRROR_ITMS_FILE}" ]] && cp "${OC_MIRROR_ITMS_FILE}" "${ARTIFACT_DIR}/disconnected-itms-generated.yaml" 2> /dev/null || true +} + +# Patch the oc-mirror-generated IDMS to ensure both quay.io and +# registry.redhat.io sources are covered, regardless of what oc-mirror +# discovered from the chart. This is needed because: +# - GA charts reference registry.redhat.io but CI verification may override to quay.io +# - CI charts reference quay.io but post-GA verification uses registry.redhat.io +# Args: +# $1 - idms_file: Path to the IDMS YAML to patch +disconnected::patch_idms() { + local idms_file=$1 + + log::info "Patching IDMS with cross-registry mirror entries" + + # Add mirror entries for the hub image from both registries + for source_registry in "quay.io" "registry.redhat.io"; do + local source="${source_registry}/${IMAGE_REPO}" + local mirror="${MIRROR_REGISTRY_URL}/${IMAGE_REPO}" + + # Skip if this source is already in the IDMS + if yq eval ".spec.imageDigestMirrors[].source" "${idms_file}" 2> /dev/null | grep -qF "${source}"; then + log::debug "IDMS already contains entry for ${source}" + continue + fi + + yq eval -i \ + ".spec.imageDigestMirrors += [{\"mirrors\": [\"${mirror}\"], \"source\": \"${source}\"}]" \ + "${idms_file}" + log::info "Added IDMS entry: ${source} → ${mirror}" + done + + # Add mirror entry for PG image if not already present. + # PG_REPO is already cleaned of @sha256 by the caller (via PG_SEPARATOR). + if [[ -n "${PG_REGISTRY:-}" && -n "${PG_REPO:-}" ]]; then + local pg_source="${PG_REGISTRY}/${PG_REPO}" + local pg_mirror="${MIRROR_REGISTRY_URL}/${PG_REPO}" + + if ! yq eval ".spec.imageDigestMirrors[].source" "${idms_file}" 2> /dev/null | grep -qF "${pg_source}"; then + yq eval -i \ + ".spec.imageDigestMirrors += [{\"mirrors\": [\"${pg_mirror}\"], \"source\": \"${pg_source}\"}]" \ + "${idms_file}" + log::info "Added IDMS entry: ${pg_source} → ${pg_mirror}" + fi + fi + + log::debug "Patched IDMS:" + log::debug "$(cat "${idms_file}")" + + cp "${idms_file}" "${ARTIFACT_DIR}/disconnected-idms-patched.yaml" 2> /dev/null || true +} + +# Fetch an external script from the rhdh-operator repository. +# Args: +# $1 - script_name: Name of the script (e.g., "mirror-plugins.sh") +# $2 - output_path: Local path to save the script +# $3 - branch: (optional) Branch name (defaults to $RELEASE_BRANCH_NAME) +disconnected::fetch_script() { + local script_name=$1 + local output_path=$2 + local branch="${3:-${RELEASE_BRANCH_NAME}}" + + local url="https://raw.githubusercontent.com/redhat-developer/rhdh-operator/refs/heads/${branch}/.rhdh/scripts/${script_name}" + + log::info "Fetching ${script_name} from rhdh-operator (branch: ${branch})..." + if ! curl -fL --max-time 30 -o "${output_path}" "${url}"; then + log::error "Failed to download ${script_name} from ${url}" + return 1 + fi + chmod +x "${output_path}" + log::success "Downloaded ${script_name} to ${output_path}" +} + +# Export functions for subshell usage (e.g., timeout bash -c "...") +export -f disconnected::require_env +export -f disconnected::setup_auth +export -f disconnected::fetch_script diff --git a/.ci/pipelines/openshift-ci-tests.sh b/.ci/pipelines/openshift-ci-tests.sh index 55b3679cbc..f6564e0b5c 100755 --- a/.ci/pipelines/openshift-ci-tests.sh +++ b/.ci/pipelines/openshift-ci-tests.sh @@ -128,6 +128,20 @@ main() { log::info "Calling handle_ocp_localization" handle_ocp_localization ;; + *ocp*disconnected*helm*nightly*) + log::info "Sourcing ocp-disconnected-helm.sh" + # shellcheck source=.ci/pipelines/jobs/ocp-disconnected-helm.sh + source "${DIR}/jobs/ocp-disconnected-helm.sh" + log::info "Calling handle_ocp_disconnected_helm" + handle_ocp_disconnected_helm + ;; + *ocp*disconnected*operator*nightly*) + log::info "Sourcing ocp-disconnected-operator.sh" + # shellcheck source=.ci/pipelines/jobs/ocp-disconnected-operator.sh + source "${DIR}/jobs/ocp-disconnected-operator.sh" + log::info "Calling handle_ocp_disconnected_operator" + handle_ocp_disconnected_operator + ;; *ocp*helm*nightly*) log::info "Sourcing ocp-nightly.sh" # shellcheck source=.ci/pipelines/jobs/ocp-nightly.sh diff --git a/.ci/pipelines/resources/disconnected/helm-post-renderer.sh b/.ci/pipelines/resources/disconnected/helm-post-renderer.sh new file mode 100755 index 0000000000..8bbda459ff --- /dev/null +++ b/.ci/pipelines/resources/disconnected/helm-post-renderer.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Helm post-renderer for disconnected deployments. +# +# Patches the RHDH Deployment's pod spec for disconnected environments: +# 1. Mounts registries.conf so the init container resolves plugin images +# from the mirror registry instead of the original registries. +# 2. Mounts the mirror registry CA at the standard container-tools +# per-registry path (/etc/containers/certs.d//ca.crt) +# so skopeo trusts the mirror's TLS certificate. +# 3. Mounts a permissive policy.json so skopeo accepts unsigned images +# from the mirror (Red Hat signature server is unreachable in +# disconnected environments). +# +# Using a post-renderer avoids the Helm "array clobber" pitfall: +# a values file that defines extraVolumes[] or initContainers[] replaces +# the chart's entire default array, losing any volumes added by newer +# chart versions. A post-renderer patches the already-rendered manifests +# so the chart's defaults are always preserved. +# +# Usage: +# helm upgrade -i ... \ +# --post-renderer ./helm-post-renderer.sh \ +# --post-renderer-args + +set -euo pipefail + +MIRROR_REGISTRY_URL="${1:?Usage: helm-post-renderer.sh }" + +yq eval " + (select(.kind == \"Deployment\" and .metadata.name == \"*-developer-hub\") | + .spec.template.spec.volumes += [ + {\"name\": \"rhdh-plugin-mirror-conf\", \"configMap\": {\"name\": \"rhdh-plugin-mirror-conf\"}}, + {\"name\": \"mirror-registry-ca\", \"configMap\": {\"name\": \"mirror-registry-ca\"}} + ] | + .spec.template.spec.initContainers[0].volumeMounts += [ + {\"mountPath\": \"/etc/containers/registries.conf.d/rhdh-registries.conf\", \"name\": \"rhdh-plugin-mirror-conf\", \"readOnly\": true, \"subPath\": \"rhdh-registries.conf\"}, + {\"mountPath\": \"/etc/containers/policy.json\", \"name\": \"rhdh-plugin-mirror-conf\", \"readOnly\": true, \"subPath\": \"policy.json\"}, + {\"mountPath\": \"/etc/containers/certs.d/${MIRROR_REGISTRY_URL}/ca.crt\", \"name\": \"mirror-registry-ca\", \"readOnly\": true, \"subPath\": \"ca.crt\"} + ] + ) // . +" diff --git a/.ci/pipelines/resources/disconnected/plugin-mirror-configmap.yaml b/.ci/pipelines/resources/disconnected/plugin-mirror-configmap.yaml new file mode 100644 index 0000000000..7b5acdb5c5 --- /dev/null +++ b/.ci/pipelines/resources/disconnected/plugin-mirror-configmap.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: rhdh-plugin-mirror-conf +data: + rhdh-registries.conf: | + [[registry]] + prefix = "registry.access.redhat.com/rhdh" + location = "${MIRROR_REGISTRY_URL}/rhdh" + + [[registry]] + prefix = "quay.io/rhdh" + location = "${MIRROR_REGISTRY_URL}/rhdh" + + [[registry]] + prefix = "ghcr.io/redhat-developer/rhdh-plugin-export-overlays" + location = "${MIRROR_REGISTRY_URL}/rhdh-plugin-export-overlays" + policy.json: | + {"default": [{"type": "insecureAcceptAnything"}]} diff --git a/.ci/pipelines/value_files/values_disconnected-smoke.yaml b/.ci/pipelines/value_files/values_disconnected-smoke.yaml new file mode 100644 index 0000000000..1a3d5dac9a --- /dev/null +++ b/.ci/pipelines/value_files/values_disconnected-smoke.yaml @@ -0,0 +1,13 @@ +# Minimal Helm values for disconnected smoke test. +# The smoke test only verifies: login page renders → guest login → homepage. +# No GitHub integrations, external secrets, dynamic plugins, or OIDC needed. +upstream: + backstage: + image: + pullPolicy: Always + appConfig: + auth: + environment: development + providers: + guest: + dangerouslyAllowOutsideDevelopment: true diff --git a/e2e-tests/playwright.config.ts b/e2e-tests/playwright.config.ts index 6c87fb3271..1bc187e1dc 100644 --- a/e2e-tests/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -6,6 +6,24 @@ import { PW_PROJECT } from "./playwright/projects"; process.env.JOB_NAME = process.env.JOB_NAME ?? ""; process.env.IS_OPENSHIFT = process.env.IS_OPENSHIFT ?? ""; +// Parse HTTPS_PROXY (http://user:pass@host:port) into Playwright's proxy +// config with separate username/password fields. No-op for connected envs. +function parseProxy( + proxyUrl: string | undefined, +): { server: string; username?: string; password?: string } | undefined { + if (proxyUrl === undefined || proxyUrl === "") return undefined; + try { + const u = new URL(proxyUrl); + return { + server: `${u.protocol}//${u.host}`, + ...(u.username !== "" && { username: decodeURIComponent(u.username) }), + ...(u.password !== "" && { password: decodeURIComponent(u.password) }), + }; + } catch { + return { server: proxyUrl }; + } +} + // Set LOCALE based on which project is being run const args = process.argv; @@ -58,6 +76,9 @@ export default defineConfig({ locale: process.env.LOCALE ?? "en", baseURL: process.env.BASE_URL, ignoreHTTPSErrors: true, + // Proxy for disconnected environments where the CI runner reaches the + // cluster through a squid proxy (HTTPS_PROXY set by proxy-conf.sh). + proxy: parseProxy(process.env.HTTPS_PROXY), trace: "on", screenshot: "on", ...devices["Desktop Chrome"],