From 496ba2f2ae8b5bdb196e2014500b9a4470ca36ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Fri, 26 Jun 2026 09:31:32 +0200 Subject: [PATCH 01/45] feat(ci): add disconnected OCP smoke test for Helm and Operator Add end-to-end disconnected CI pipeline handlers that deploy RHDH in an isolated OCP cluster and run a Playwright smoke test. Helm path uses oc-mirror v2 (downloaded at runtime) for chart + image mirroring, following the documented air-gapped workflow: - GA (registry.redhat.io): chart from charts.openshift.io, oc-mirror discovers and mirrors default images automatically - CI/upstream: chart from oci://quay.io/rhdh/chart via helm.local, override images added as additionalImages - oc-mirror generates IDMS, patched with cross-registry entries for both quay.io and registry.redhat.io hub image sources - Chart installed from local tgz in oc-mirror workspace Operator path uses prepare-restricted-environment.sh from rhdh-operator for operator+operand mirroring and installation (documented approach). Both paths share: - Auth setup (REGISTRY_AUTH_FILE + XDG_RUNTIME_DIR/containers/auth.json) - Plugin mirroring via mirror-plugins.sh with registries.conf covering registry.access.redhat.com/rhdh, quay.io/rhdh, and ghcr.io/redhat-developer/rhdh-plugin-export-overlays (6 CI plugins) - Helm overrides for registries.conf volume mount (avoids array clobber) - CATALOG_INDEX_IMAGE override support for CI build verification Dispatcher routing in openshift-ci-tests.sh for *ocp*disconnected*helm*nightly* and *ocp*disconnected*operator*nightly* patterns, positioned before generic *ocp*helm*nightly* to prevent false matches. Assisted-by: OpenCode --- .ci/pipelines/jobs/ocp-disconnected-helm.sh | 196 ++++++++++++ .../jobs/ocp-disconnected-operator.sh | 129 ++++++++ .ci/pipelines/lib/disconnected.sh | 287 ++++++++++++++++++ .ci/pipelines/openshift-ci-tests.sh | 14 + .../disconnected/helm-overrides.yaml | 52 ++++ .../disconnected/plugin-mirror-configmap.yaml | 17 ++ 6 files changed, 695 insertions(+) create mode 100644 .ci/pipelines/jobs/ocp-disconnected-helm.sh create mode 100644 .ci/pipelines/jobs/ocp-disconnected-operator.sh create mode 100644 .ci/pipelines/lib/disconnected.sh create mode 100644 .ci/pipelines/resources/disconnected/helm-overrides.yaml create mode 100644 .ci/pipelines/resources/disconnected/plugin-mirror-configmap.yaml diff --git a/.ci/pipelines/jobs/ocp-disconnected-helm.sh b/.ci/pipelines/jobs/ocp-disconnected-helm.sh new file mode 100644 index 0000000000..4925e410d1 --- /dev/null +++ b/.ci/pipelines/jobs/ocp-disconnected-helm.sh @@ -0,0 +1,196 @@ +#!/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 + + # --- Section A: Install oc-mirror --- + log::section "oc-mirror Setup" + + disconnected::install_oc_mirror || { + log::error "Failed to install oc-mirror — aborting" + return 1 + } + + # --- Section B: Resolve chart source and pull locally --- + log::section "Chart Resolution" + + local is_ga="false" + if [[ "${IMAGE_REGISTRY}" == "registry.redhat.io" ]]; then + is_ga="true" + fi + + if [[ "${is_ga}" == "true" ]]; then + # GA: pull chart from charts.openshift.io + 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 + # CI/upstream: pull chart from OCI registry + 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}" + + # --- Section C: Resolve PostgreSQL image from chart --- + local helm_values + helm_values=$(helm show values "${CHART_LOCAL_TGZ}" 2> /dev/null || true) + + export PG_REGISTRY PG_REPO PG_TAG + 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}" + + log::info "PostgreSQL image from chart: ${PG_REGISTRY}/${PG_REPO}:${PG_TAG}" + + # --- Section D: Build ImageSetConfiguration --- + 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 + } + + # --- Section E: Run oc-mirror --- + local workspace="${DISCONNECTED_TMPDIR}/oc-mirror-workspace" + disconnected::run_oc_mirror "${imageset_config}" "${workspace}" || { + log::error "oc-mirror failed — aborting" + return 1 + } + + # --- Section F: Patch and apply IDMS --- + 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 + + # --- Section G: Plugin mirroring --- + 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 + } + + # --- Section H: Namespace + registries.conf ConfigMap --- + 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}" + + # --- Section I: Helm deployment from mirrored chart --- + 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 + + helm upgrade -i "${RELEASE_NAME}" -n "${NAME_SPACE}" \ + "${chart_install_path}" \ + -f "${DIR}/value_files/${HELM_CHART_VALUE_FILE_NAME}" \ + -f "${DIR}/resources/disconnected/helm-overrides.yaml" \ + "${helm_set_flags[@]}" || { + log::error "Helm deployment failed" + return 1 + } + + log::success "RHDH deployed via Helm with mirrored images" + + # --- Section J: Smoke test --- + log::section "Smoke Test" + + 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..0c6093a63b --- /dev/null +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -0,0 +1,129 @@ +#!/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 + + # --- Section A: Operator Mirroring + Installation --- + # 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 + + 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" + + # --- Section B: Wait for Operator CRD --- + k8s_wait::crd "backstages.rhdh.redhat.com" 300 10 || { + log::error "Backstage CRD not available after operator installation" + return 1 + } + + # --- Section C: Plugin Mirroring --- + 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 + } + + # --- Section D: Namespace + registries.conf ConfigMap --- + log::section "Cluster Resources" + + 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}" + + # --- Section E: Backstage CR Deployment --- + 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}" + + deploy_rhdh_operator "${NAME_SPACE}" "${cr_temp}" + log::success "Backstage CR deployed in ${NAME_SPACE}" + + # --- Section F: Smoke Test --- + 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..619a54c85d --- /dev/null +++ b/.ci/pipelines/lib/disconnected.sh @@ -0,0 +1,287 @@ +#!/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 + +# oc-mirror binary path, set by disconnected::install_oc_mirror. +OC_MIRROR_BIN="" +export OC_MIRROR_BIN + +# 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}" +} + +# Download the oc-mirror binary at runtime from mirror.openshift.com. +# Uses CONTAINER_PLATFORM_VERSION to select the matching version. +disconnected::install_oc_mirror() { + local arch + arch=$(uname -m) + case ${arch} in + x86_64) arch="amd64" ;; + aarch64) arch="arm64" ;; + esac + + local ocp_version="${CONTAINER_PLATFORM_VERSION:-4.21}" + local oc_mirror_version="" + + # Try stable channel for the OCP minor version, fall back to latest + local stable_url="https://mirror.openshift.com/pub/openshift-v4/${arch}/clients/ocp/stable-${ocp_version}/" + if curl -sf --head --connect-timeout 10 "${stable_url}" > /dev/null 2>&1; then + oc_mirror_version="stable-${ocp_version}" + log::info "Using oc-mirror from stable-${ocp_version} channel" + else + oc_mirror_version="latest" + log::info "stable-${ocp_version} not available, using oc-mirror from latest channel" + fi + + local download_dir="${DISCONNECTED_TMPDIR}/oc-mirror-download" + mkdir -p "${download_dir}" + + local base_url="https://mirror.openshift.com/pub/openshift-v4/${arch}/clients/ocp/${oc_mirror_version}" + + log::info "Downloading oc-mirror from ${base_url}/" + if ! curl -fL --retry 5 --connect-timeout 30 -o "${download_dir}/oc-mirror.tar.gz" "${base_url}/oc-mirror.tar.gz"; then + log::error "Failed to download oc-mirror" + return 1 + fi + + # Verify checksum + if curl -fL --retry 3 --connect-timeout 30 -o "${download_dir}/sha256sum.txt" "${base_url}/sha256sum.txt" 2> /dev/null; then + if grep "oc-mirror.tar.gz" "${download_dir}/sha256sum.txt" | (cd "${download_dir}" && sha256sum -c -); then + log::info "oc-mirror checksum verified" + else + log::warn "oc-mirror checksum verification failed — continuing anyway" + fi + fi + + tar -xzf "${download_dir}/oc-mirror.tar.gz" -C "${download_dir}" + chmod +x "${download_dir}/oc-mirror" + OC_MIRROR_BIN="${download_dir}/oc-mirror" + export OC_MIRROR_BIN + + log::success "oc-mirror installed: $(${OC_MIRROR_BIN} version --output=yaml 2>&1 | head -1)" +} + +# 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. + if [[ "${PG_REGISTRY:-registry.redhat.io}" != "registry.redhat.io" ]]; then + additional_images+=("${PG_REGISTRY}/${PG_REPO}:${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}")" +} + +# 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}" + + log::info "Running oc-mirror --v2 → ${MIRROR_REGISTRY_URL}" + if ! "${OC_MIRROR_BIN}" \ + -c "${imageset_config}" \ + "docker://${MIRROR_REGISTRY_URL}" \ + --dest-tls-verify=false \ + --v2 \ + --workspace "file://${workspace_dir}"; then + log::error "oc-mirror failed" + return 1 + 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}" +} + +# 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 + 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}")" +} + +# 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-overrides.yaml b/.ci/pipelines/resources/disconnected/helm-overrides.yaml new file mode 100644 index 0000000000..83b1fd197c --- /dev/null +++ b/.ci/pipelines/resources/disconnected/helm-overrides.yaml @@ -0,0 +1,52 @@ +# Helm values override for disconnected deployments. +# Adds the registries.conf ConfigMap volume + mount to the +# install-dynamic-plugins init container. +# +# Using a separate -f file avoids the Helm "array clobber" pitfall: +# --set on initContainers[]/extraVolumes[] replaces the entire array, +# losing the chart defaults. The -f merge keeps chart-default volumes +# intact and only adds the ConfigMap volume + mount. +upstream: + backstage: + extraVolumes: + - name: dynamic-plugins-root + emptyDir: {} + - name: dynamic-plugins + configMap: + defaultMode: 420 + name: dynamic-plugins + optional: true + - name: dynamic-plugins-npmrc + secret: + defaultMode: 420 + optional: true + secretName: dynamic-plugins-npmrc + - name: rhdh-plugin-mirror-conf + configMap: + name: rhdh-plugin-mirror-conf + initContainers: + - name: install-dynamic-plugins + command: + - ./install-dynamic-plugins.sh + - /dynamic-plugins-root + env: + - name: NPM_CONFIG_USERCONFIG + value: /opt/app-root/src/.npmrc.dynamic-plugins + image: null + imagePullPolicy: Always + volumeMounts: + - mountPath: /dynamic-plugins-root + name: dynamic-plugins-root + - mountPath: /opt/app-root/src/dynamic-plugins.yaml + name: dynamic-plugins + readOnly: true + subPath: dynamic-plugins.yaml + - mountPath: /opt/app-root/src/.npmrc.dynamic-plugins + name: dynamic-plugins-npmrc + readOnly: true + subPath: .npmrc + - mountPath: /etc/containers/registries.conf.d/rhdh-registries.conf + name: rhdh-plugin-mirror-conf + readOnly: true + subPath: rhdh-registries.conf + workingDir: /opt/app-root/src 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..2e14f555c9 --- /dev/null +++ b/.ci/pipelines/resources/disconnected/plugin-mirror-configmap.yaml @@ -0,0 +1,17 @@ +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" From a69732a3dab301ba78887e16729357f917fec1df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Mon, 29 Jun 2026 09:50:38 +0200 Subject: [PATCH 02/45] fix(ci): work around rhdh-operator yq install bug in disconnected operator handler Use the fixed prepare-restricted-environment.sh from redhat-developer/rhdh-operator#3109 until it merges. The upstream script has a bug where INSTALL_YQ=0 still triggers the yq install path because [[ 0 ]] is truthy in bash, causing a 'mv: cannot move yq_linux_amd64 to /tmp/.local/bin/yq_mf: No such file or directory' failure. Assisted-by: OpenCode --- .ci/pipelines/jobs/ocp-disconnected-operator.sh | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index 0c6093a63b..bad402e144 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -33,11 +33,16 @@ handle_ocp_disconnected_operator() { # 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 - } + # TODO: revert to disconnected::fetch_script once redhat-developer/rhdh-operator#3109 merges. + # The upstream script has a bug where INSTALL_YQ=0 triggers the yq install + # path because [[ 0 ]] is truthy in bash. Use the fixed version from the PR. + local _prepare_url="https://raw.githubusercontent.com/redhat-developer/rhdh-operator/refs/pull/3109/head/.rhdh/scripts/prepare-restricted-environment.sh" + log::info "Fetching prepare-restricted-environment.sh (fixed: rhdh-operator#3109)..." + if ! curl -fL --max-time 30 -o "${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" "${_prepare_url}"; then + log::error "Failed to download prepare-restricted-environment.sh — aborting" + return 1 + fi + chmod +x "${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" local prepare_args=( --to-registry "${MIRROR_REGISTRY_URL}" From e88b2d5f2c08075de8e6b112f8582b9a1b869f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Mon, 29 Jun 2026 10:04:00 +0200 Subject: [PATCH 03/45] fix(ci): unset REGISTRY_AUTH_FILE for oc-mirror and save artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit oc-mirror panics when REGISTRY_AUTH_FILE is set because the distribution/distribution library interprets it as a storage driver config, not a container auth file path. Unset it before running oc-mirror and restore after — oc-mirror reads auth from ${XDG_RUNTIME_DIR}/containers/auth.json instead. Save key artifacts to ARTIFACT_DIR with disconnected- prefix for post-failure debugging: ImageSetConfiguration, generated IDMS/ITMS, patched IDMS, chart values, rendered ConfigMap, helm set flags, and Backstage CR. Assisted-by: OpenCode --- .ci/pipelines/jobs/ocp-disconnected-helm.sh | 7 ++++++ .../jobs/ocp-disconnected-operator.sh | 5 +++++ .ci/pipelines/lib/disconnected.sh | 22 +++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/.ci/pipelines/jobs/ocp-disconnected-helm.sh b/.ci/pipelines/jobs/ocp-disconnected-helm.sh index 4925e410d1..4ff19d8d75 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-helm.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-helm.sh @@ -86,6 +86,8 @@ handle_ocp_disconnected_helm() { log::info "PostgreSQL image from chart: ${PG_REGISTRY}/${PG_REPO}:${PG_TAG}" + echo "${helm_values}" > "${ARTIFACT_DIR}/disconnected-helm-chart-values.yaml" 2> /dev/null || true + # --- Section D: Build ImageSetConfiguration --- log::section "Image Mirroring" @@ -151,6 +153,9 @@ handle_ocp_disconnected_helm() { } 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 + # --- Section I: Helm deployment from mirrored chart --- log::section "Helm Deployment" @@ -186,6 +191,8 @@ handle_ocp_disconnected_helm() { 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 + # --- Section J: Smoke test --- log::section "Smoke Test" diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index bad402e144..ecd88fea22 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -103,6 +103,9 @@ handle_ocp_disconnected_operator() { } 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 + # --- Section E: Backstage CR Deployment --- log::section "Backstage CR Deployment" @@ -121,6 +124,8 @@ handle_ocp_disconnected_operator() { 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}" diff --git a/.ci/pipelines/lib/disconnected.sh b/.ci/pipelines/lib/disconnected.sh index 619a54c85d..e95a829b4c 100644 --- a/.ci/pipelines/lib/disconnected.sh +++ b/.ci/pipelines/lib/disconnected.sh @@ -163,6 +163,8 @@ EOF 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. @@ -176,6 +178,13 @@ disconnected::run_oc_mirror() { 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_BIN}" \ -c "${imageset_config}" \ @@ -183,10 +192,17 @@ disconnected::run_oc_mirror() { --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) @@ -212,6 +228,10 @@ disconnected::run_oc_mirror() { 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 @@ -258,6 +278,8 @@ disconnected::patch_idms() { 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. From 77a0884faed6220bfe5b091816fa57d1f0befdeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Mon, 29 Jun 2026 12:18:30 +0200 Subject: [PATCH 04/45] fix(ci): sanitize chart repo paths for IDMS and install podman for operator Strip @sha256 suffixes from chart-extracted repository paths before constructing IDMS entries. The RHDH Helm chart encodes digest references as repository: "repo@sha256" + tag: "", but IDMS source/mirror fields require clean registry/repo paths without @. Uses ${var%@*} which is a no-op on paths without @. Install podman at runtime for the operator disconnected handler. prepare-restricted-environment.sh requires podman for building custom operator index images. Assisted-by: OpenCode --- .ci/pipelines/jobs/ocp-disconnected-helm.sh | 14 ++++++++++++-- .ci/pipelines/jobs/ocp-disconnected-operator.sh | 7 +++++++ .ci/pipelines/lib/disconnected.sh | 6 ++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-helm.sh b/.ci/pipelines/jobs/ocp-disconnected-helm.sh index 4ff19d8d75..0108cf7b8d 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-helm.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-helm.sh @@ -76,7 +76,7 @@ handle_ocp_disconnected_helm() { local helm_values helm_values=$(helm show values "${CHART_LOCAL_TGZ}" 2> /dev/null || true) - export PG_REGISTRY PG_REPO PG_TAG + 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) @@ -84,7 +84,17 @@ handle_ocp_disconnected_helm() { PG_REPO="${PG_REPO:-rhel9/postgresql-15}" PG_TAG="${PG_TAG:-latest}" - log::info "PostgreSQL image from chart: ${PG_REGISTRY}/${PG_REPO}:${PG_TAG}" + # 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 diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index ecd88fea22..a1e3b9051e 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -31,8 +31,15 @@ handle_ocp_disconnected_operator() { # --- Section A: Operator Mirroring + Installation --- # Uses prepare-restricted-environment.sh from rhdh-operator, which handles # mirroring operator/operand images and installing the operator CatalogSource. + # Requires podman for building custom operator index images. log::section "Operator Mirroring and Installation" + if ! command -v podman &> /dev/null; then + log::info "Installing podman (required by prepare-restricted-environment.sh)..." + apt-get update -qq > /dev/null 2>&1 && apt-get install -y -qq podman > /dev/null 2>&1 + log::success "podman installed: $(podman --version)" + fi + # TODO: revert to disconnected::fetch_script once redhat-developer/rhdh-operator#3109 merges. # The upstream script has a bug where INSTALL_YQ=0 triggers the yq install # path because [[ 0 ]] is truthy in bash. Use the fixed version from the PR. diff --git a/.ci/pipelines/lib/disconnected.sh b/.ci/pipelines/lib/disconnected.sh index e95a829b4c..ef9f09a3a7 100644 --- a/.ci/pipelines/lib/disconnected.sh +++ b/.ci/pipelines/lib/disconnected.sh @@ -148,8 +148,9 @@ EOF # 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_TAG}") + additional_images+=("${PG_REGISTRY}/${PG_REPO}${PG_SEPARATOR}${PG_TAG}") fi if [[ ${#additional_images[@]} -gt 0 ]]; then @@ -263,7 +264,8 @@ disconnected::patch_idms() { log::info "Added IDMS entry: ${source} → ${mirror}" done - # Add mirror entry for PG image if not already present + # 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}" From 1002effb632f9f8b4b250bdbcc774568c3ca6892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Mon, 29 Jun 2026 15:16:47 +0200 Subject: [PATCH 05/45] fix(ci): fix helm init container image, podman install, and revert yq workaround MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Helm: replace image: null in helm-overrides.yaml with an envsubst placeholder (${MIRROR_REGISTRY_URL}/${IMAGE_REPO}:${TAG_NAME}). The chart validates initContainers[0].image as a string, rejecting null. The overrides file is now rendered through envsubst before passing to helm upgrade -i. Operator: make podman install visible (don't suppress output) and verify it succeeds before continuing. If apt-get fails (e.g. repos unreachable via proxy), abort with a clear message instead of silently proceeding. Revert the rhdh-operator#3109 PR ref workaround — the yq install bug has been fixed upstream. Use disconnected::fetch_script again. Assisted-by: OpenCode --- .ci/pipelines/jobs/ocp-disconnected-helm.sh | 7 +++++- .../jobs/ocp-disconnected-operator.sh | 23 ++++++++++--------- .../disconnected/helm-overrides.yaml | 2 +- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-helm.sh b/.ci/pipelines/jobs/ocp-disconnected-helm.sh index 0108cf7b8d..7d79b7c394 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-helm.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-helm.sh @@ -190,10 +190,15 @@ handle_ocp_disconnected_helm() { ) fi + # Render the overrides with envsubst (resolves image refs for init container) + local rendered_overrides="${DISCONNECTED_TMPDIR}/helm-overrides-rendered.yaml" + envsubst < "${DIR}/resources/disconnected/helm-overrides.yaml" > "${rendered_overrides}" + cp "${rendered_overrides}" "${ARTIFACT_DIR}/disconnected-helm-overrides-rendered.yaml" 2> /dev/null || true + helm upgrade -i "${RELEASE_NAME}" -n "${NAME_SPACE}" \ "${chart_install_path}" \ -f "${DIR}/value_files/${HELM_CHART_VALUE_FILE_NAME}" \ - -f "${DIR}/resources/disconnected/helm-overrides.yaml" \ + -f "${rendered_overrides}" \ "${helm_set_flags[@]}" || { log::error "Helm deployment failed" return 1 diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index a1e3b9051e..aa51779977 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -36,20 +36,21 @@ handle_ocp_disconnected_operator() { if ! command -v podman &> /dev/null; then log::info "Installing podman (required by prepare-restricted-environment.sh)..." - apt-get update -qq > /dev/null 2>&1 && apt-get install -y -qq podman > /dev/null 2>&1 + apt-get update -qq 2>&1 + apt-get install -y -qq podman 2>&1 + hash -r + if ! command -v podman &> /dev/null; then + log::error "Failed to install podman. Add podman to the rhdh-e2e-runner Dockerfile." + return 1 + fi log::success "podman installed: $(podman --version)" fi - # TODO: revert to disconnected::fetch_script once redhat-developer/rhdh-operator#3109 merges. - # The upstream script has a bug where INSTALL_YQ=0 triggers the yq install - # path because [[ 0 ]] is truthy in bash. Use the fixed version from the PR. - local _prepare_url="https://raw.githubusercontent.com/redhat-developer/rhdh-operator/refs/pull/3109/head/.rhdh/scripts/prepare-restricted-environment.sh" - log::info "Fetching prepare-restricted-environment.sh (fixed: rhdh-operator#3109)..." - if ! curl -fL --max-time 30 -o "${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" "${_prepare_url}"; then - log::error "Failed to download prepare-restricted-environment.sh — aborting" - return 1 - fi - chmod +x "${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" + 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}" diff --git a/.ci/pipelines/resources/disconnected/helm-overrides.yaml b/.ci/pipelines/resources/disconnected/helm-overrides.yaml index 83b1fd197c..cff992745c 100644 --- a/.ci/pipelines/resources/disconnected/helm-overrides.yaml +++ b/.ci/pipelines/resources/disconnected/helm-overrides.yaml @@ -32,7 +32,7 @@ upstream: env: - name: NPM_CONFIG_USERCONFIG value: /opt/app-root/src/.npmrc.dynamic-plugins - image: null + image: "${MIRROR_REGISTRY_URL}/${IMAGE_REPO}:${TAG_NAME}" imagePullPolicy: Always volumeMounts: - mountPath: /dynamic-plugins-root From 7fd04b08c009de4863341b7c844d5833735df436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Tue, 30 Jun 2026 10:26:55 +0200 Subject: [PATCH 06/45] fix(disconnected): replace static helm-overrides with post-renderer Replace the static helm-overrides.yaml values file with a Helm post-renderer script that appends the registries.conf volume and mount to the already-rendered Deployment manifests. This avoids the Helm 'array clobber' pitfall: a values file that defines extraVolumes[] or initContainers[] replaces the chart's entire default array, losing volumes added by newer chart versions. The post-renderer patches the final manifests so the chart's defaults are always preserved regardless of chart version changes. Also improves operator podman install error visibility by removing -qq flags and adding proper error checking for apt-get commands. Assisted-by: OpenCode --- .ci/pipelines/jobs/ocp-disconnected-helm.sh | 11 ++-- .../jobs/ocp-disconnected-operator.sh | 10 ++-- .../disconnected/helm-overrides.yaml | 52 ------------------- .../disconnected/helm-post-renderer.sh | 22 ++++++++ 4 files changed, 35 insertions(+), 60 deletions(-) delete mode 100644 .ci/pipelines/resources/disconnected/helm-overrides.yaml create mode 100755 .ci/pipelines/resources/disconnected/helm-post-renderer.sh diff --git a/.ci/pipelines/jobs/ocp-disconnected-helm.sh b/.ci/pipelines/jobs/ocp-disconnected-helm.sh index 7d79b7c394..1601a59fcf 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-helm.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-helm.sh @@ -190,15 +190,16 @@ handle_ocp_disconnected_helm() { ) fi - # Render the overrides with envsubst (resolves image refs for init container) - local rendered_overrides="${DISCONNECTED_TMPDIR}/helm-overrides-rendered.yaml" - envsubst < "${DIR}/resources/disconnected/helm-overrides.yaml" > "${rendered_overrides}" - cp "${rendered_overrides}" "${ARTIFACT_DIR}/disconnected-helm-overrides-rendered.yaml" 2> /dev/null || true + # Post-renderer appends registries.conf volume + mount to the rendered + # Deployment, avoiding the Helm "array clobber" pitfall where a values + # file that defines extraVolumes[] replaces the chart's entire default + # array (which grows across chart versions). + 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/${HELM_CHART_VALUE_FILE_NAME}" \ - -f "${rendered_overrides}" \ + --post-renderer "${post_renderer}" \ "${helm_set_flags[@]}" || { log::error "Helm deployment failed" return 1 diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index aa51779977..f41937ce7f 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -36,11 +36,15 @@ handle_ocp_disconnected_operator() { if ! command -v podman &> /dev/null; then log::info "Installing podman (required by prepare-restricted-environment.sh)..." - apt-get update -qq 2>&1 - apt-get install -y -qq podman 2>&1 + if ! apt-get update 2>&1; then + log::warn "apt-get update failed — continuing anyway" + fi + if ! apt-get install -y podman 2>&1; then + log::error "apt-get install podman failed" + fi hash -r if ! command -v podman &> /dev/null; then - log::error "Failed to install podman. Add podman to the rhdh-e2e-runner Dockerfile." + log::error "podman not available after install. Add podman to the rhdh-e2e-runner Dockerfile." return 1 fi log::success "podman installed: $(podman --version)" diff --git a/.ci/pipelines/resources/disconnected/helm-overrides.yaml b/.ci/pipelines/resources/disconnected/helm-overrides.yaml deleted file mode 100644 index cff992745c..0000000000 --- a/.ci/pipelines/resources/disconnected/helm-overrides.yaml +++ /dev/null @@ -1,52 +0,0 @@ -# Helm values override for disconnected deployments. -# Adds the registries.conf ConfigMap volume + mount to the -# install-dynamic-plugins init container. -# -# Using a separate -f file avoids the Helm "array clobber" pitfall: -# --set on initContainers[]/extraVolumes[] replaces the entire array, -# losing the chart defaults. The -f merge keeps chart-default volumes -# intact and only adds the ConfigMap volume + mount. -upstream: - backstage: - extraVolumes: - - name: dynamic-plugins-root - emptyDir: {} - - name: dynamic-plugins - configMap: - defaultMode: 420 - name: dynamic-plugins - optional: true - - name: dynamic-plugins-npmrc - secret: - defaultMode: 420 - optional: true - secretName: dynamic-plugins-npmrc - - name: rhdh-plugin-mirror-conf - configMap: - name: rhdh-plugin-mirror-conf - initContainers: - - name: install-dynamic-plugins - command: - - ./install-dynamic-plugins.sh - - /dynamic-plugins-root - env: - - name: NPM_CONFIG_USERCONFIG - value: /opt/app-root/src/.npmrc.dynamic-plugins - image: "${MIRROR_REGISTRY_URL}/${IMAGE_REPO}:${TAG_NAME}" - imagePullPolicy: Always - volumeMounts: - - mountPath: /dynamic-plugins-root - name: dynamic-plugins-root - - mountPath: /opt/app-root/src/dynamic-plugins.yaml - name: dynamic-plugins - readOnly: true - subPath: dynamic-plugins.yaml - - mountPath: /opt/app-root/src/.npmrc.dynamic-plugins - name: dynamic-plugins-npmrc - readOnly: true - subPath: .npmrc - - mountPath: /etc/containers/registries.conf.d/rhdh-registries.conf - name: rhdh-plugin-mirror-conf - readOnly: true - subPath: rhdh-registries.conf - workingDir: /opt/app-root/src 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..79b02c8f83 --- /dev/null +++ b/.ci/pipelines/resources/disconnected/helm-post-renderer.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Helm post-renderer for disconnected deployments. +# +# Appends the registries.conf ConfigMap volume and volumeMount to the +# RHDH Deployment's pod spec and install-dynamic-plugins init container. +# +# 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 + +set -euo pipefail + +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"}}] | + .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"}] + ) // . +' From 614484333e9a07ef217f9958bb5b668d51418c58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Tue, 30 Jun 2026 13:48:06 +0200 Subject: [PATCH 07/45] fix(disconnected): use minimal values for helm smoke test The full values_showcase.yaml references ConfigMaps (app-config-rhdh, dynamic-plugins-config) and secrets (rhdh-secrets) that are created by the normal apply_yaml_files() flow, which the disconnected handler skips. This caused pods to be stuck in Init:0/2 waiting for missing ConfigMaps. Add a minimal values file that provides only what the smoke test needs: guest auth with dangerouslyAllowOutsideDevelopment. The chart defaults handle everything else (internal PostgreSQL, backend secret, default dynamic plugins, route). Assisted-by: OpenCode --- .ci/pipelines/jobs/ocp-disconnected-helm.sh | 2 +- .../value_files/values_disconnected-smoke.yaml | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 .ci/pipelines/value_files/values_disconnected-smoke.yaml diff --git a/.ci/pipelines/jobs/ocp-disconnected-helm.sh b/.ci/pipelines/jobs/ocp-disconnected-helm.sh index 1601a59fcf..715642c0c9 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-helm.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-helm.sh @@ -198,7 +198,7 @@ handle_ocp_disconnected_helm() { helm upgrade -i "${RELEASE_NAME}" -n "${NAME_SPACE}" \ "${chart_install_path}" \ - -f "${DIR}/value_files/${HELM_CHART_VALUE_FILE_NAME}" \ + -f "${DIR}/value_files/values_disconnected-smoke.yaml" \ --post-renderer "${post_renderer}" \ "${helm_set_flags[@]}" || { log::error "Helm deployment failed" 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 From eea16cb8a055ee13a25437e429b973d268e5523c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Tue, 30 Jun 2026 15:25:36 +0200 Subject: [PATCH 08/45] fix(disconnected): mount mirror registry CA in init container The install-dynamic-plugins init container runs skopeo to fetch the plugin catalog index. IDMS redirects quay.io pulls to the mirror registry, but skopeo inside the pod doesn't trust the mirror's self-signed CA certificate (x509: certificate signed by unknown authority). Create a ConfigMap containing the system CA bundle concatenated with the mirror registry CA, and mount it in the init container at /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem (Go's default system cert store path on RHEL/UBI). Assisted-by: OpenCode --- .ci/pipelines/jobs/ocp-disconnected-helm.sh | 14 ++++++++++++++ .../disconnected/helm-post-renderer.sh | 17 +++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-helm.sh b/.ci/pipelines/jobs/ocp-disconnected-helm.sh index 715642c0c9..d6c79f6bbe 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-helm.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-helm.sh @@ -166,6 +166,20 @@ handle_ocp_disconnected_helm() { envsubst < "${DIR}/resources/disconnected/plugin-mirror-configmap.yaml" \ > "${ARTIFACT_DIR}/disconnected-plugin-mirror-configmap.yaml" 2> /dev/null || true + # Combined CA bundle: system CAs + mirror registry CA. + # The init container's skopeo needs to trust the mirror registry when + # IDMS redirects quay.io/registry.redhat.io pulls to the mirror. + local combined_ca="${DISCONNECTED_TMPDIR}/combined-ca-bundle.crt" + cat /etc/pki/tls/certs/ca-bundle.crt "${MIRROR_REGISTRY_CA}" > "${combined_ca}" + oc create configmap mirror-registry-ca \ + --from-file="tls-ca-bundle.pem=${combined_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}" + # --- Section I: Helm deployment from mirrored chart --- log::section "Helm Deployment" diff --git a/.ci/pipelines/resources/disconnected/helm-post-renderer.sh b/.ci/pipelines/resources/disconnected/helm-post-renderer.sh index 79b02c8f83..406c043ced 100755 --- a/.ci/pipelines/resources/disconnected/helm-post-renderer.sh +++ b/.ci/pipelines/resources/disconnected/helm-post-renderer.sh @@ -1,8 +1,11 @@ #!/bin/bash # Helm post-renderer for disconnected deployments. # -# Appends the registries.conf ConfigMap volume and volumeMount to the -# RHDH Deployment's pod spec and install-dynamic-plugins init container. +# 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 a combined CA bundle (system CAs + mirror registry CA) so +# skopeo can verify the mirror registry's TLS certificate. # # Using a post-renderer avoids the Helm "array clobber" pitfall: # a values file that defines extraVolumes[] or initContainers[] replaces @@ -16,7 +19,13 @@ set -euo pipefail 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"}}] | - .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"}] + .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/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", "name": "mirror-registry-ca", "readOnly": true, "subPath": "tls-ca-bundle.pem"} + ] ) // . ' From 74b2a5303cdc299fd30f60c4a86bc992b6054ad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Wed, 1 Jul 2026 09:02:26 +0200 Subject: [PATCH 09/45] fix(disconnected): use per-registry CA path for mirror TLS trust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the standard container-tools per-registry CA mechanism instead of replacing the system trust store. Mount just the mirror registry CA at /etc/containers/certs.d//ca.crt — skopeo reads this path natively, no system CA bundle concatenation needed. The mirror registry URL is passed via --post-renderer-args so the mount path is constructed dynamically per CI run. Fixes: cat /etc/pki/tls/certs/ca-bundle.crt failing on the Ubuntu-based CI runner (Debian uses /etc/ssl/certs/). Assisted-by: OpenCode --- .ci/pipelines/jobs/ocp-disconnected-helm.sh | 12 ++++----- .../disconnected/helm-post-renderer.sh | 26 ++++++++++++------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-helm.sh b/.ci/pipelines/jobs/ocp-disconnected-helm.sh index d6c79f6bbe..47955cd81c 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-helm.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-helm.sh @@ -166,13 +166,12 @@ handle_ocp_disconnected_helm() { envsubst < "${DIR}/resources/disconnected/plugin-mirror-configmap.yaml" \ > "${ARTIFACT_DIR}/disconnected-plugin-mirror-configmap.yaml" 2> /dev/null || true - # Combined CA bundle: system CAs + mirror registry CA. - # The init container's skopeo needs to trust the mirror registry when - # IDMS redirects quay.io/registry.redhat.io pulls to the mirror. - local combined_ca="${DISCONNECTED_TMPDIR}/combined-ca-bundle.crt" - cat /etc/pki/tls/certs/ca-bundle.crt "${MIRROR_REGISTRY_CA}" > "${combined_ca}" + # 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="tls-ca-bundle.pem=${combined_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" @@ -214,6 +213,7 @@ handle_ocp_disconnected_helm() { "${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 diff --git a/.ci/pipelines/resources/disconnected/helm-post-renderer.sh b/.ci/pipelines/resources/disconnected/helm-post-renderer.sh index 406c043ced..68a6bd6de5 100755 --- a/.ci/pipelines/resources/disconnected/helm-post-renderer.sh +++ b/.ci/pipelines/resources/disconnected/helm-post-renderer.sh @@ -4,8 +4,9 @@ # 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 a combined CA bundle (system CAs + mirror registry CA) so -# skopeo can verify the mirror registry's TLS certificate. +# 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. # # Using a post-renderer avoids the Helm "array clobber" pitfall: # a values file that defines extraVolumes[] or initContainers[] replaces @@ -13,19 +14,24 @@ # 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 +# Usage: +# helm upgrade -i ... \ +# --post-renderer ./helm-post-renderer.sh \ +# --post-renderer-args set -euo pipefail -yq eval ' - (select(.kind == "Deployment" and .metadata.name == "*-developer-hub") | +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"}} + {\"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/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", "name": "mirror-registry-ca", "readOnly": true, "subPath": "tls-ca-bundle.pem"} + {\"mountPath\": \"/etc/containers/registries.conf.d/rhdh-registries.conf\", \"name\": \"rhdh-plugin-mirror-conf\", \"readOnly\": true, \"subPath\": \"rhdh-registries.conf\"}, + {\"mountPath\": \"/etc/containers/certs.d/${MIRROR_REGISTRY_URL}/ca.crt\", \"name\": \"mirror-registry-ca\", \"readOnly\": true, \"subPath\": \"ca.crt\"} ] ) // . -' +" From ceda5da0b2ee2f41e12ec28e225b616058eeea78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Wed, 1 Jul 2026 22:12:46 +0200 Subject: [PATCH 10/45] fix(disconnected): provide registry auth for init container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install-dynamic-plugins init container uses skopeo to fetch the plugin catalog index. With IDMS redirecting quay.io to the mirror registry, skopeo gets 'authentication required' because the pod has no credentials for the mirror. Create the -dynamic-plugins-registry-auth secret from the combined pull secret. The chart already mounts this optional secret at /opt/app-root/src/.config/containers in the init container — no post-renderer changes needed. Assisted-by: OpenCode --- .ci/pipelines/jobs/ocp-disconnected-helm.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.ci/pipelines/jobs/ocp-disconnected-helm.sh b/.ci/pipelines/jobs/ocp-disconnected-helm.sh index 47955cd81c..8eae350afb 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-helm.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-helm.sh @@ -179,6 +179,18 @@ handle_ocp_disconnected_helm() { } 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}" + # --- Section I: Helm deployment from mirrored chart --- log::section "Helm Deployment" From 18ab4beaf671be6806242be8bee0839e06cf1fe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Thu, 2 Jul 2026 11:25:54 +0200 Subject: [PATCH 11/45] refactor(disconnected): remove runtime tool installs (oc-mirror, podman) Both oc-mirror and podman are now pre-installed in the e2e-runner image (PR #5036). Remove: - disconnected::install_oc_mirror function and OC_MIRROR_BIN variable - Runtime apt-get install podman block in operator handler - Section lettering from both handlers Assisted-by: OpenCode --- .ci/pipelines/jobs/ocp-disconnected-helm.sh | 28 ++------- .../jobs/ocp-disconnected-operator.sh | 25 +------- .ci/pipelines/lib/disconnected.sh | 57 +------------------ 3 files changed, 7 insertions(+), 103 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-helm.sh b/.ci/pipelines/jobs/ocp-disconnected-helm.sh index 8eae350afb..ab69227b17 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-helm.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-helm.sh @@ -26,15 +26,6 @@ handle_ocp_disconnected_helm() { K8S_CLUSTER_ROUTER_BASE=$(oc get route console -n openshift-console -o=jsonpath='{.spec.host}' | sed 's/^[^.]*\.//') export K8S_CLUSTER_ROUTER_BASE - # --- Section A: Install oc-mirror --- - log::section "oc-mirror Setup" - - disconnected::install_oc_mirror || { - log::error "Failed to install oc-mirror — aborting" - return 1 - } - - # --- Section B: Resolve chart source and pull locally --- log::section "Chart Resolution" local is_ga="false" @@ -43,7 +34,6 @@ handle_ocp_disconnected_helm() { fi if [[ "${is_ga}" == "true" ]]; then - # GA: pull chart from charts.openshift.io 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})" @@ -54,7 +44,6 @@ handle_ocp_disconnected_helm() { return 1 } else - # CI/upstream: pull chart from OCI registry log::info "Pulling CI chart from ${HELM_CHART_URL} (version: ${CHART_VERSION})" helm pull "${HELM_CHART_URL}" --version "${CHART_VERSION}" \ -d "${DISCONNECTED_TMPDIR}" || { @@ -72,7 +61,7 @@ handle_ocp_disconnected_helm() { fi log::success "Chart pulled: ${CHART_LOCAL_TGZ}" - # --- Section C: Resolve PostgreSQL image from chart --- + # Resolve PostgreSQL image from chart values local helm_values helm_values=$(helm show values "${CHART_LOCAL_TGZ}" 2> /dev/null || true) @@ -98,7 +87,6 @@ handle_ocp_disconnected_helm() { echo "${helm_values}" > "${ARTIFACT_DIR}/disconnected-helm-chart-values.yaml" 2> /dev/null || true - # --- Section D: Build ImageSetConfiguration --- log::section "Image Mirroring" local imageset_config="${DISCONNECTED_TMPDIR}/imageset-config.yaml" @@ -107,14 +95,12 @@ handle_ocp_disconnected_helm() { return 1 } - # --- Section E: Run oc-mirror --- local workspace="${DISCONNECTED_TMPDIR}/oc-mirror-workspace" disconnected::run_oc_mirror "${imageset_config}" "${workspace}" || { log::error "oc-mirror failed — aborting" return 1 } - # --- Section F: Patch and apply IDMS --- log::section "Cluster Resources" disconnected::patch_idms "${OC_MIRROR_IDMS_FILE}" @@ -133,7 +119,6 @@ handle_ocp_disconnected_helm() { log::success "ImageTagMirrorSet applied" fi - # --- Section G: Plugin mirroring --- log::section "Plugin Mirroring" disconnected::fetch_script "mirror-plugins.sh" "${DISCONNECTED_TMPDIR}/mirror-plugins.sh" || { @@ -153,7 +138,8 @@ handle_ocp_disconnected_helm() { return 1 } - # --- Section H: Namespace + registries.conf ConfigMap --- + log::section "Namespace and Secrets" + namespace::configure "${NAME_SPACE}" envsubst < "${DIR}/resources/disconnected/plugin-mirror-configmap.yaml" \ @@ -191,7 +177,6 @@ handle_ocp_disconnected_helm() { } log::success "Secret ${RELEASE_NAME}-dynamic-plugins-registry-auth created in ${NAME_SPACE}" - # --- Section I: Helm deployment from mirrored chart --- log::section "Helm Deployment" # Prefer the chart from oc-mirror workspace, fall back to the pulled tgz @@ -215,10 +200,8 @@ handle_ocp_disconnected_helm() { ) fi - # Post-renderer appends registries.conf volume + mount to the rendered - # Deployment, avoiding the Helm "array clobber" pitfall where a values - # file that defines extraVolumes[] replaces the chart's entire default - # array (which grows across chart versions). + # 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}" \ @@ -235,7 +218,6 @@ handle_ocp_disconnected_helm() { printf '%s\n' "${helm_set_flags[@]}" > "${ARTIFACT_DIR}/disconnected-helm-set-flags.txt" 2> /dev/null || true - # --- Section J: Smoke test --- log::section "Smoke Test" local url="https://${RELEASE_NAME}-developer-hub-${NAME_SPACE}.${K8S_CLUSTER_ROUTER_BASE}" diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index f41937ce7f..df8d7c1733 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -28,28 +28,10 @@ handle_ocp_disconnected_operator() { K8S_CLUSTER_ROUTER_BASE=$(oc get route console -n openshift-console -o=jsonpath='{.spec.host}' | sed 's/^[^.]*\.//') export K8S_CLUSTER_ROUTER_BASE - # --- Section A: Operator Mirroring + Installation --- # Uses prepare-restricted-environment.sh from rhdh-operator, which handles # mirroring operator/operand images and installing the operator CatalogSource. - # Requires podman for building custom operator index images. log::section "Operator Mirroring and Installation" - if ! command -v podman &> /dev/null; then - log::info "Installing podman (required by prepare-restricted-environment.sh)..." - if ! apt-get update 2>&1; then - log::warn "apt-get update failed — continuing anyway" - fi - if ! apt-get install -y podman 2>&1; then - log::error "apt-get install podman failed" - fi - hash -r - if ! command -v podman &> /dev/null; then - log::error "podman not available after install. Add podman to the rhdh-e2e-runner Dockerfile." - return 1 - fi - log::success "podman installed: $(podman --version)" - fi - disconnected::fetch_script "prepare-restricted-environment.sh" "${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" \ || { log::error "Failed to fetch prepare-restricted-environment.sh — aborting" @@ -76,13 +58,11 @@ handle_ocp_disconnected_operator() { } log::success "Operator installed via prepare-restricted-environment.sh" - # --- Section B: Wait for Operator CRD --- k8s_wait::crd "backstages.rhdh.redhat.com" 300 10 || { log::error "Backstage CRD not available after operator installation" return 1 } - # --- Section C: Plugin Mirroring --- log::section "Plugin Mirroring" disconnected::fetch_script "mirror-plugins.sh" "${DISCONNECTED_TMPDIR}/mirror-plugins.sh" \ @@ -103,8 +83,7 @@ handle_ocp_disconnected_operator() { return 1 } - # --- Section D: Namespace + registries.conf ConfigMap --- - log::section "Cluster Resources" + log::section "Namespace and Secrets" namespace::configure "${NAME_SPACE}" @@ -118,7 +97,6 @@ handle_ocp_disconnected_operator() { envsubst < "${DIR}/resources/disconnected/plugin-mirror-configmap.yaml" \ > "${ARTIFACT_DIR}/disconnected-plugin-mirror-configmap.yaml" 2> /dev/null || true - # --- Section E: Backstage CR Deployment --- log::section "Backstage CR Deployment" local rendered_cr @@ -141,7 +119,6 @@ handle_ocp_disconnected_operator() { deploy_rhdh_operator "${NAME_SPACE}" "${cr_temp}" log::success "Backstage CR deployed in ${NAME_SPACE}" - # --- Section F: Smoke Test --- log::section "Smoke Test" local url="https://backstage-${RELEASE_NAME}-${NAME_SPACE}.${K8S_CLUSTER_ROUTER_BASE}" diff --git a/.ci/pipelines/lib/disconnected.sh b/.ci/pipelines/lib/disconnected.sh index ef9f09a3a7..fa5171eee3 100644 --- a/.ci/pipelines/lib/disconnected.sh +++ b/.ci/pipelines/lib/disconnected.sh @@ -22,10 +22,6 @@ source "${DIR}/lib/common.sh" DISCONNECTED_TMPDIR=$(mktemp -d) export DISCONNECTED_TMPDIR -# oc-mirror binary path, set by disconnected::install_oc_mirror. -OC_MIRROR_BIN="" -export OC_MIRROR_BIN - # Validate that all required disconnected environment variables are set. # These are exported by the step-registry commands.sh before calling # openshift-ci-tests.sh. @@ -63,57 +59,6 @@ disconnected::setup_auth() { log::info "Container auth configured from ${MIRROR_REGISTRY_PULL_SECRET}" } -# Download the oc-mirror binary at runtime from mirror.openshift.com. -# Uses CONTAINER_PLATFORM_VERSION to select the matching version. -disconnected::install_oc_mirror() { - local arch - arch=$(uname -m) - case ${arch} in - x86_64) arch="amd64" ;; - aarch64) arch="arm64" ;; - esac - - local ocp_version="${CONTAINER_PLATFORM_VERSION:-4.21}" - local oc_mirror_version="" - - # Try stable channel for the OCP minor version, fall back to latest - local stable_url="https://mirror.openshift.com/pub/openshift-v4/${arch}/clients/ocp/stable-${ocp_version}/" - if curl -sf --head --connect-timeout 10 "${stable_url}" > /dev/null 2>&1; then - oc_mirror_version="stable-${ocp_version}" - log::info "Using oc-mirror from stable-${ocp_version} channel" - else - oc_mirror_version="latest" - log::info "stable-${ocp_version} not available, using oc-mirror from latest channel" - fi - - local download_dir="${DISCONNECTED_TMPDIR}/oc-mirror-download" - mkdir -p "${download_dir}" - - local base_url="https://mirror.openshift.com/pub/openshift-v4/${arch}/clients/ocp/${oc_mirror_version}" - - log::info "Downloading oc-mirror from ${base_url}/" - if ! curl -fL --retry 5 --connect-timeout 30 -o "${download_dir}/oc-mirror.tar.gz" "${base_url}/oc-mirror.tar.gz"; then - log::error "Failed to download oc-mirror" - return 1 - fi - - # Verify checksum - if curl -fL --retry 3 --connect-timeout 30 -o "${download_dir}/sha256sum.txt" "${base_url}/sha256sum.txt" 2> /dev/null; then - if grep "oc-mirror.tar.gz" "${download_dir}/sha256sum.txt" | (cd "${download_dir}" && sha256sum -c -); then - log::info "oc-mirror checksum verified" - else - log::warn "oc-mirror checksum verification failed — continuing anyway" - fi - fi - - tar -xzf "${download_dir}/oc-mirror.tar.gz" -C "${download_dir}" - chmod +x "${download_dir}/oc-mirror" - OC_MIRROR_BIN="${download_dir}/oc-mirror" - export OC_MIRROR_BIN - - log::success "oc-mirror installed: $(${OC_MIRROR_BIN} version --output=yaml 2>&1 | head -1)" -} - # 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 @@ -187,7 +132,7 @@ disconnected::run_oc_mirror() { unset REGISTRY_AUTH_FILE log::info "Running oc-mirror --v2 → ${MIRROR_REGISTRY_URL}" - if ! "${OC_MIRROR_BIN}" \ + if ! oc-mirror \ -c "${imageset_config}" \ "docker://${MIRROR_REGISTRY_URL}" \ --dest-tls-verify=false \ From a54df0921c6fe0b14bc6060921600f53674b61b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Thu, 2 Jul 2026 14:10:07 +0200 Subject: [PATCH 12/45] fix(disconnected): skip signature verification for mirrored plugins The install-dynamic-plugins init container fails to install Lightspeed plugins from the mirror registry: Source image rejected: A signature was required, but no signature exists The RHDH image ships a policy.json (RHDHBUGS-2799) that requires Red Hat GPG signatures for registry.access.redhat.com images. In a disconnected environment, IDMS redirects these pulls to the mirror registry, but the mirror doesn't host Red Hat's signature store. Add a permissive policy.json to the existing rhdh-plugin-mirror-conf ConfigMap and mount it at /etc/containers/policy.json in the init container. This is the standard approach for air-gapped environments where the signature server is unreachable. Assisted-by: OpenCode --- .ci/pipelines/resources/disconnected/helm-post-renderer.sh | 4 ++++ .../resources/disconnected/plugin-mirror-configmap.yaml | 2 ++ 2 files changed, 6 insertions(+) diff --git a/.ci/pipelines/resources/disconnected/helm-post-renderer.sh b/.ci/pipelines/resources/disconnected/helm-post-renderer.sh index 68a6bd6de5..8bbda459ff 100755 --- a/.ci/pipelines/resources/disconnected/helm-post-renderer.sh +++ b/.ci/pipelines/resources/disconnected/helm-post-renderer.sh @@ -7,6 +7,9 @@ # 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 @@ -31,6 +34,7 @@ yq eval " ] | .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 index 2e14f555c9..7b5acdb5c5 100644 --- a/.ci/pipelines/resources/disconnected/plugin-mirror-configmap.yaml +++ b/.ci/pipelines/resources/disconnected/plugin-mirror-configmap.yaml @@ -15,3 +15,5 @@ data: [[registry]] prefix = "ghcr.io/redhat-developer/rhdh-plugin-export-overlays" location = "${MIRROR_REGISTRY_URL}/rhdh-plugin-export-overlays" + policy.json: | + {"default": [{"type": "insecureAcceptAnything"}]} From a298b77bd580996e8dbd966402a3b540d54ba115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Fri, 3 Jul 2026 09:36:01 +0200 Subject: [PATCH 13/45] fix(disconnected): configure proxy for Playwright and skip signature verification Two issues fixed: 1. Playwright page.goto timeout (50s) on disconnected clusters: The CI runner accesses the test cluster through a squid proxy (HTTPS_PROXY set by proxy-conf.sh). curl uses it automatically, but Playwright's Chromium does not pick up the env var without explicit configuration. Add proxy to Playwright's use config, reading from HTTPS_PROXY when set (no-op for connected envs). 2. Lightspeed plugin signature verification failure: 'Source image rejected: A signature was required, but no signature exists' The RHDH image's policy.json (RHDHBUGS-2799) requires Red Hat GPG signatures for registry.access.redhat.com images. In disconnected environments, IDMS redirects to the mirror which lacks signatures. Mount a permissive policy.json in the init container via the existing rhdh-plugin-mirror-conf ConfigMap. Assisted-by: OpenCode --- e2e-tests/playwright.config.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/e2e-tests/playwright.config.ts b/e2e-tests/playwright.config.ts index 9f055740b8..5c58af559a 100644 --- a/e2e-tests/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -61,6 +61,12 @@ 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: + process.env.HTTPS_PROXY !== undefined && process.env.HTTPS_PROXY !== "" + ? { server: process.env.HTTPS_PROXY } + : undefined, trace: "retain-on-failure", screenshot: "on", ...devices["Desktop Chrome"], From 2df41ea9579e73bd79fb7ef3d268e1b0e44a64bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Sat, 4 Jul 2026 10:58:34 +0200 Subject: [PATCH 14/45] fix(disconnected): set BUILDAH_ISOLATION=chroot for operator handler The CI pod runs with nested_podman: true (hostUsers: false), placing it inside a Linux user namespace. When prepare-restricted-environment.sh calls podman build, podman tries to create another user namespace inside the existing one, which fails with: newuidmap: open of uid_map failed: Permission denied Error: cannot set up namespace using /usr/bin/newuidmap: exit status 1 Export BUILDAH_ISOLATION=chroot before invoking the script so all podman build / buildah calls use chroot isolation instead of nested user namespaces. The env var is respected by both podman and buildah without needing to modify the downstream rhdh-operator script. Assisted-by: OpenCode --- .ci/pipelines/jobs/ocp-disconnected-operator.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index df8d7c1733..fab3eb85b2 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -51,6 +51,12 @@ handle_ocp_disconnected_operator() { ) fi + # The CI pod runs with nested_podman: true (hostUsers: false), placing it + # inside a Linux user namespace. podman build tries to create another user + # namespace, which fails with "newuidmap: open of uid_map failed: Permission + # denied". BUILDAH_ISOLATION=chroot uses chroot instead of nested namespaces. + export BUILDAH_ISOLATION=chroot + bash "${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" "${prepare_args[@]}" \ || { log::error "prepare-restricted-environment.sh failed — aborting" From d784cdb2f52ce2c4884d9470d9bc50817e16c2ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Sat, 4 Jul 2026 13:31:26 +0200 Subject: [PATCH 15/45] fix(disconnected): parse proxy credentials and fix nested podman MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues fixed: 1. Playwright proxy credentials not parsed: HTTPS_PROXY is http://user:pass@host:3128 (credentials in URL). Playwright requires credentials as separate username/password fields, not embedded in the server URL. Add parseProxy() helper that extracts them via URL parsing. 2. Operator podman build fails with nested user namespaces: 'newuidmap: open of uid_map failed: Permission denied' BUILDAH_ISOLATION=chroot alone is insufficient — the error occurs during podman's rootless storage setup before build isolation applies. Set _CONTAINERS_USERNS_CONFIGURED=1 to skip newuidmap and force vfs storage driver to avoid fuse-overlayfs userns ops. 3. Added debug logging for proxy config and podman environment to aid future CI troubleshooting. Assisted-by: OpenCode --- .ci/pipelines/jobs/ocp-disconnected-helm.sh | 4 +++ .../jobs/ocp-disconnected-operator.sh | 25 ++++++++++++++++--- e2e-tests/playwright.config.ts | 23 ++++++++++++++--- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-helm.sh b/.ci/pipelines/jobs/ocp-disconnected-helm.sh index ab69227b17..781ccfd979 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-helm.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-helm.sh @@ -220,6 +220,10 @@ handle_ocp_disconnected_helm() { 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}" diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index fab3eb85b2..f13b5dbac2 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -52,10 +52,29 @@ handle_ocp_disconnected_operator() { fi # The CI pod runs with nested_podman: true (hostUsers: false), placing it - # inside a Linux user namespace. podman build tries to create another user - # namespace, which fails with "newuidmap: open of uid_map failed: Permission - # denied". BUILDAH_ISOLATION=chroot uses chroot instead of nested namespaces. + # 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 + ignore_chown_errors — vfs avoids fuse-overlayfs + # userns ops; ignore_chown_errors prevents "chown: operation not + # permitted" when podman can't chown storage files as non-root + export _CONTAINERS_USERNS_CONFIGURED=1 export BUILDAH_ISOLATION=chroot + mkdir -p "${HOME}/.config/containers" + cat > "${HOME}/.config/containers/storage.conf" << 'EOF' +[storage] +driver = "vfs" + +[storage.options] +ignore_chown_errors = "true" +EOF + + 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[@]}" \ || { diff --git a/e2e-tests/playwright.config.ts b/e2e-tests/playwright.config.ts index 5c58af559a..9dc2e5cb80 100644 --- a/e2e-tests/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -8,6 +8,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; @@ -63,10 +81,7 @@ export default defineConfig({ 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: - process.env.HTTPS_PROXY !== undefined && process.env.HTTPS_PROXY !== "" - ? { server: process.env.HTTPS_PROXY } - : undefined, + proxy: parseProxy(process.env.HTTPS_PROXY), trace: "retain-on-failure", screenshot: "on", ...devices["Desktop Chrome"], From e24154aba4c4487c991c63c919896db8a8e0887e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Mon, 13 Jul 2026 13:30:05 +0200 Subject: [PATCH 16/45] fix(disconnected): disable lightspeed and orchestrator for smoke test The chart enables lightspeed by default, adding a lightspeed-core sidecar, lightspeed-rag-init init container, and volumes referencing ConfigMaps (lightspeed-config-stack, lightspeed-config-rhdh-profile) that don't exist in the disconnected namespace. The lightspeed-core image also requires mirroring from registry.redhat.io. None of this is needed for a smoke test (guest login + homepage). Disable all optional features explicitly. Assisted-by: OpenCode --- .ci/pipelines/value_files/values_disconnected-smoke.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.ci/pipelines/value_files/values_disconnected-smoke.yaml b/.ci/pipelines/value_files/values_disconnected-smoke.yaml index 1a3d5dac9a..eb85cd46f6 100644 --- a/.ci/pipelines/value_files/values_disconnected-smoke.yaml +++ b/.ci/pipelines/value_files/values_disconnected-smoke.yaml @@ -1,6 +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. +# All optional features (lightspeed, orchestrator) are disabled to avoid +# pulling extra images and referencing missing ConfigMaps/Secrets. +global: + lightspeed: + enabled: false +orchestrator: + enabled: false upstream: backstage: image: From 6633232c8cd305e8fef13c6ba979e56386c582d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Mon, 13 Jul 2026 17:03:59 +0200 Subject: [PATCH 17/45] fix(disconnected): mirror catalog index image and fix operator storage Two issues fixed: 1. Catalog index not mirrored (helm init container crash): 'manifest unknown' when pulling plugin-catalog-index by digest The chart references the catalog index by digest (@sha256:...) but it was only mirrored by tag (next) via mirror-plugins.sh. Extract the catalog index image from chart values (same digest encoding pattern as PostgreSQL) and add it to oc-mirror's additionalImages so it is mirrored by digest. 2. Operator podman storage chown failure: 'chown .../storage/vfs/dir: operation not permitted' ignore_chown_errors only covers layer operations, not graphroot directory creation. Set graphroot/runroot to pre-created temp directories owned by uid 1000, and export CONTAINERS_STORAGE_CONF to ensure podman reads the correct config. Assisted-by: OpenCode --- .ci/pipelines/jobs/ocp-disconnected-helm.sh | 16 +++++++++++++++ .../jobs/ocp-disconnected-operator.sh | 20 ++++++++++++++----- .ci/pipelines/lib/disconnected.sh | 6 ++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-helm.sh b/.ci/pipelines/jobs/ocp-disconnected-helm.sh index 781ccfd979..0890e5b777 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-helm.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-helm.sh @@ -85,6 +85,22 @@ handle_ocp_disconnected_helm() { log::info "PostgreSQL image from chart: ${PG_REGISTRY}/${PG_REPO}${PG_SEPARATOR}${PG_TAG}" + # Resolve catalog index image (same digest encoding as PG). + # The init container pulls this by digest; it must be in the mirror. + export CI_REGISTRY CI_REPO CI_TAG CI_SEPARATOR + CI_REGISTRY=$(echo "${helm_values}" | yq '.global.catalogIndex.image.registry' || true) + CI_REPO=$(echo "${helm_values}" | yq '.global.catalogIndex.image.repository' || true) + CI_TAG=$(echo "${helm_values}" | yq '.global.catalogIndex.image.tag' || true) + CI_REGISTRY="${CI_REGISTRY:-quay.io}" + CI_REPO="${CI_REPO:-rhdh/plugin-catalog-index}" + CI_TAG="${CI_TAG:-latest}" + CI_SEPARATOR=":" + if [[ "${CI_REPO}" == *"@"* ]]; then + CI_SEPARATOR="@${CI_REPO##*@}:" + CI_REPO="${CI_REPO%@*}" + fi + log::info "Catalog index from chart: ${CI_REGISTRY}/${CI_REPO}${CI_SEPARATOR}${CI_TAG}" + echo "${helm_values}" > "${ARTIFACT_DIR}/disconnected-helm-chart-values.yaml" 2> /dev/null || true log::section "Image Mirroring" diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index f13b5dbac2..e2d5d03839 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -55,25 +55,35 @@ handle_ocp_disconnected_operator() { # 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 + # And chowns the storage graphroot, which fails with: + # chown .../storage/vfs/dir: operation not permitted # Fix: # _CONTAINERS_USERNS_CONFIGURED=1 — skip newuidmap (userns already set up) # BUILDAH_ISOLATION=chroot — chroot instead of user namespace for builds - # storage driver=vfs + ignore_chown_errors — vfs avoids fuse-overlayfs - # userns ops; ignore_chown_errors prevents "chown: operation not - # permitted" when podman can't chown storage files as non-root + # graphroot/runroot in DISCONNECTED_TMPDIR — pre-owned by uid 1000, so + # podman skips chown during storage init + # ignore_chown_errors — safety net for layer operations export _CONTAINERS_USERNS_CONFIGURED=1 export BUILDAH_ISOLATION=chroot + + local podman_storage="${DISCONNECTED_TMPDIR}/podman-storage" + local podman_run="${DISCONNECTED_TMPDIR}/podman-run" + mkdir -p "${podman_storage}" "${podman_run}" + mkdir -p "${HOME}/.config/containers" - cat > "${HOME}/.config/containers/storage.conf" << 'EOF' + cat > "${HOME}/.config/containers/storage.conf" << EOF [storage] driver = "vfs" +graphroot = "${podman_storage}" +runroot = "${podman_run}" [storage.options] ignore_chown_errors = "true" EOF + export CONTAINERS_STORAGE_CONF="${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 "Storage config: $(cat "${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[@]}" \ diff --git a/.ci/pipelines/lib/disconnected.sh b/.ci/pipelines/lib/disconnected.sh index fa5171eee3..24dcd3f5c5 100644 --- a/.ci/pipelines/lib/disconnected.sh +++ b/.ci/pipelines/lib/disconnected.sh @@ -98,6 +98,12 @@ EOF additional_images+=("${PG_REGISTRY}/${PG_REPO}${PG_SEPARATOR}${PG_TAG}") fi + # Catalog index: the chart references it by digest and the init container + # pulls it at startup. Must be mirrored so IDMS can redirect the pull. + if [[ -n "${CI_REGISTRY:-}" && -n "${CI_REPO:-}" && -n "${CI_TAG:-}" ]]; then + additional_images+=("${CI_REGISTRY}/${CI_REPO}${CI_SEPARATOR:-:}${CI_TAG}") + fi + if [[ ${#additional_images[@]} -gt 0 ]]; then { echo " additionalImages:" From 702a34e25333929fff03f019bacece6fee9456d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Mon, 13 Jul 2026 21:38:30 +0200 Subject: [PATCH 18/45] fix(disconnected): write storage.conf to isolated path and reset podman The entrypoint.sh from the nested-podman image creates a storage.conf at ${HOME}/.config/containers/storage.conf and may initialize podman storage at the default graphroot before our commands.sh runs. Writing our config to the same path doesn't take effect because podman's storage is already initialized at the default path. Fix: write storage.conf to DISCONNECTED_TMPDIR (outside ${HOME}) and point CONTAINERS_STORAGE_CONF there. Run 'podman system reset --force' to clear any pre-initialized storage state from the entrypoint. Assisted-by: OpenCode --- .ci/pipelines/jobs/ocp-disconnected-operator.sh | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index e2d5d03839..7a17763b78 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -70,8 +70,11 @@ handle_ocp_disconnected_operator() { local podman_run="${DISCONNECTED_TMPDIR}/podman-run" mkdir -p "${podman_storage}" "${podman_run}" - mkdir -p "${HOME}/.config/containers" - cat > "${HOME}/.config/containers/storage.conf" << EOF + # Write storage.conf to DISCONNECTED_TMPDIR (not ${HOME}/.config/containers/) + # to avoid conflicting with the entrypoint's config. CONTAINERS_STORAGE_CONF + # takes precedence over user/system defaults. + local storage_conf="${DISCONNECTED_TMPDIR}/storage.conf" + cat > "${storage_conf}" << EOF [storage] driver = "vfs" graphroot = "${podman_storage}" @@ -80,10 +83,13 @@ runroot = "${podman_run}" [storage.options] ignore_chown_errors = "true" EOF - export CONTAINERS_STORAGE_CONF="${HOME}/.config/containers/storage.conf" + export CONTAINERS_STORAGE_CONF="${storage_conf}" + + # Clear any pre-initialized storage state from the entrypoint + podman system reset --force 2> /dev/null || true log::info "Podman environment: uid=$(id -u), BUILDAH_ISOLATION=${BUILDAH_ISOLATION}" - log::info "Storage config: $(cat "${CONTAINERS_STORAGE_CONF}" | tr '\n' ' ')" + log::info "Storage config (${CONTAINERS_STORAGE_CONF}): $(tr '\n' ' ' < "${storage_conf}")" log::info "subuid: $(cat /etc/subuid 2> /dev/null || echo 'not found')" bash "${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" "${prepare_args[@]}" \ From fd82c10c1751f3c34b5c27d7202f14b85e9a4e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Tue, 14 Jul 2026 08:35:58 +0200 Subject: [PATCH 19/45] fix(disconnected): pass proxy to global-setup and fix operator storage Two fixes: 1. Playwright global-setup DNS resolution failure: playwrightRequest.newContext() in global-setup.ts did not pass the proxy option. Unlike browser contexts (which inherit use.proxy from playwright.config.ts), APIRequestContext resolves DNS locally on the CI runner pod, which cannot resolve cluster *.apps hostnames. Extract parseProxy() to a shared utility and pass it to newContext(). 2. Operator podman 'chown operation not permitted': The CI commands.sh sets HOME=/tmp. The entrypoint.sh wrote a default storage.conf (no graphroot) at /home/user/.config/containers/ during image init. With HOME=/tmp, podman falls back to the default graphroot at /tmp/.local/share/containers/storage/ which fails chown in the user namespace. Fix: also install our custom storage.conf at the user-level path (HOME/.config/containers/storage.conf). Assisted-by: OpenCode --- .../jobs/ocp-disconnected-operator.sh | 13 +++++++--- e2e-tests/playwright.config.ts | 19 +------------- e2e-tests/playwright/global-setup.ts | 5 ++++ e2e-tests/playwright/utils/proxy.ts | 25 +++++++++++++++++++ 4 files changed, 41 insertions(+), 21 deletions(-) create mode 100644 e2e-tests/playwright/utils/proxy.ts diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index 7a17763b78..747809fb98 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -70,9 +70,8 @@ handle_ocp_disconnected_operator() { local podman_run="${DISCONNECTED_TMPDIR}/podman-run" mkdir -p "${podman_storage}" "${podman_run}" - # Write storage.conf to DISCONNECTED_TMPDIR (not ${HOME}/.config/containers/) - # to avoid conflicting with the entrypoint's config. CONTAINERS_STORAGE_CONF - # takes precedence over user/system defaults. + # Write storage.conf with explicit graphroot in our tmpdir so podman/buildah + # never fall back to the default HOME-based path. local storage_conf="${DISCONNECTED_TMPDIR}/storage.conf" cat > "${storage_conf}" << EOF [storage] @@ -85,6 +84,14 @@ ignore_chown_errors = "true" EOF export CONTAINERS_STORAGE_CONF="${storage_conf}" + # Also install the config at the user-level path (HOME/.config/containers/). + # The CI commands.sh sets HOME=/tmp; the entrypoint.sh wrote a default config + # at /home/user/.config/containers/ (the original HOME) which has no graphroot. + # Some podman versions or downstream scripts may ignore CONTAINERS_STORAGE_CONF + # and read the user-level config instead, so we overwrite it at both locations. + mkdir -p "${HOME}/.config/containers" + cp "${storage_conf}" "${HOME}/.config/containers/storage.conf" + # Clear any pre-initialized storage state from the entrypoint podman system reset --force 2> /dev/null || true diff --git a/e2e-tests/playwright.config.ts b/e2e-tests/playwright.config.ts index 9dc2e5cb80..15a9acdc6a 100644 --- a/e2e-tests/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -4,28 +4,11 @@ import type { ReporterDescription } from "@playwright/test"; /* oxlint-disable import/no-unassigned-import -- intentional side-effect graph wiring */ import "./playwright/entry-graph"; import { PW_PROJECT } from "./playwright/projects"; +import { parseProxy } from "./playwright/utils/proxy"; 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; diff --git a/e2e-tests/playwright/global-setup.ts b/e2e-tests/playwright/global-setup.ts index 83c4eb9566..a3d634fe43 100644 --- a/e2e-tests/playwright/global-setup.ts +++ b/e2e-tests/playwright/global-setup.ts @@ -1,5 +1,6 @@ import { request as playwrightRequest } from "@playwright/test"; +import { parseProxy } from "./utils/proxy"; import { ensureRuntimeDeployed } from "./utils/runtime-deploy"; import { waitForRhdhReady } from "./utils/wait-for-rhdh-ready"; @@ -27,6 +28,10 @@ export default async function globalSetup(): Promise { const request = await playwrightRequest.newContext({ baseURL, ignoreHTTPSErrors: true, + // In disconnected environments the CI runner reaches the cluster through a + // squid proxy (HTTPS_PROXY). Unlike browser contexts which inherit the + // proxy from playwright.config.ts, APIRequestContext needs it explicitly. + proxy: parseProxy(process.env.HTTPS_PROXY), }); try { await waitForRhdhReady(request); diff --git a/e2e-tests/playwright/utils/proxy.ts b/e2e-tests/playwright/utils/proxy.ts new file mode 100644 index 0000000000..b2777043ba --- /dev/null +++ b/e2e-tests/playwright/utils/proxy.ts @@ -0,0 +1,25 @@ +/** + * Parse an HTTP(S) proxy URL into Playwright's proxy config. + * + * Playwright's `proxy` option expects `username` and `password` as separate + * fields, but CI environments typically set a single `HTTPS_PROXY` URL like + * `http://user:pass@host:3128`. This helper splits the URL into the shape + * Playwright needs. + * + * Returns `undefined` when no proxy URL is provided (connected environments). + */ +export 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 }; + } +} From 7ed75164d1b0877455ee11a881ba930f79b1c6d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Tue, 14 Jul 2026 13:02:14 +0200 Subject: [PATCH 20/45] fix(disconnected): wait for MCP rollout and fix rootless podman storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Helm: After applying IDMS/ITMS, the MachineConfigOperator triggers a rolling update of cluster nodes (drain, reboot). Deploying Helm immediately causes pods to be evicted mid-startup — backstage connects to PG before it's ready and never recovers (503 forever). Fix: wait for all MachineConfigPools to reach Updated=True before deploying. Operator: The c/storage library's getRootlessStorageOpts() ignores the 'graphroot' field in storage.conf for rootless users and defaults to $HOME/.local/share/containers/storage (where chown fails in the CI user namespace). The only field that overrides this is 'rootless_storage_path'. Also set XDG_DATA_HOME so the fallback computation also points to our tmpdir. Move 'podman system reset' before writing configs since some podman versions delete $HOME/.config/containers/ during reset. Assisted-by: OpenCode --- .ci/pipelines/jobs/ocp-disconnected-helm.sh | 9 ++++ .../jobs/ocp-disconnected-operator.sh | 44 ++++++++++++++----- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-helm.sh b/.ci/pipelines/jobs/ocp-disconnected-helm.sh index 0890e5b777..783dfc9607 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-helm.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-helm.sh @@ -135,6 +135,15 @@ handle_ocp_disconnected_helm() { log::success "ImageTagMirrorSet applied" fi + # IDMS/ITMS changes trigger a MachineConfig update which rolls worker nodes + # (drain → apply config → reboot). Wait for all MachineConfigPools to finish + # before deploying workloads, otherwise pods get evicted mid-startup. + log::info "Waiting for MachineConfigPool updates to complete (up to 20m)..." + if ! oc wait machineconfigpool --all --for=condition=Updated=True --timeout=20m; then + log::warn "MachineConfigPool wait timed out — proceeding anyway" + fi + log::success "All MachineConfigPools are Updated" + log::section "Plugin Mirroring" disconnected::fetch_script "mirror-plugins.sh" "${DISCONNECTED_TMPDIR}/mirror-plugins.sh" || { diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index 747809fb98..37830df527 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -60,8 +60,10 @@ handle_ocp_disconnected_operator() { # Fix: # _CONTAINERS_USERNS_CONFIGURED=1 — skip newuidmap (userns already set up) # BUILDAH_ISOLATION=chroot — chroot instead of user namespace for builds - # graphroot/runroot in DISCONNECTED_TMPDIR — pre-owned by uid 1000, so - # podman skips chown during storage init + # rootless_storage_path — the only field that getRootlessStorageOpts() + # checks before falling back to $HOME/.local/share/containers/storage; + # plain "graphroot" is overwritten by the rootless path logic + # XDG_DATA_HOME — controls homedir.GetDataHome() fallback in c/storage # ignore_chown_errors — safety net for layer operations export _CONTAINERS_USERNS_CONFIGURED=1 export BUILDAH_ISOLATION=chroot @@ -70,13 +72,27 @@ handle_ocp_disconnected_operator() { local podman_run="${DISCONNECTED_TMPDIR}/podman-run" mkdir -p "${podman_storage}" "${podman_run}" - # Write storage.conf with explicit graphroot in our tmpdir so podman/buildah - # never fall back to the default HOME-based path. + # Point XDG_DATA_HOME to our tmpdir so the c/storage library's + # getRootlessStorageOpts() computes graphroot under our directory + # instead of defaulting to $HOME/.local/share/containers/storage. + export XDG_DATA_HOME="${DISCONNECTED_TMPDIR}/data" + mkdir -p "${XDG_DATA_HOME}" + + # Clear any pre-initialized storage state from the entrypoint BEFORE + # writing our config. podman system reset deletes $HOME/.config/containers/ + # in some versions, so we must run it first. + podman system reset --force 2> /dev/null || true + + # Write storage.conf with rootless_storage_path — the only field that + # containers/storage's getRootlessStorageOpts() respects for overriding + # the rootless graphroot. Plain "graphroot" is overwritten by rootless + # path logic (it falls back to $XDG_DATA_HOME/containers/storage). local storage_conf="${DISCONNECTED_TMPDIR}/storage.conf" cat > "${storage_conf}" << EOF [storage] driver = "vfs" graphroot = "${podman_storage}" +rootless_storage_path = "${podman_storage}" runroot = "${podman_run}" [storage.options] @@ -85,19 +101,16 @@ EOF export CONTAINERS_STORAGE_CONF="${storage_conf}" # Also install the config at the user-level path (HOME/.config/containers/). - # The CI commands.sh sets HOME=/tmp; the entrypoint.sh wrote a default config - # at /home/user/.config/containers/ (the original HOME) which has no graphroot. - # Some podman versions or downstream scripts may ignore CONTAINERS_STORAGE_CONF - # and read the user-level config instead, so we overwrite it at both locations. + # The prepare-restricted-environment.sh script invokes podman build which + # may read this path instead of CONTAINERS_STORAGE_CONF. mkdir -p "${HOME}/.config/containers" cp "${storage_conf}" "${HOME}/.config/containers/storage.conf" - # Clear any pre-initialized storage state from the entrypoint - podman system reset --force 2> /dev/null || true - log::info "Podman environment: uid=$(id -u), BUILDAH_ISOLATION=${BUILDAH_ISOLATION}" log::info "Storage config (${CONTAINERS_STORAGE_CONF}): $(tr '\n' ' ' < "${storage_conf}")" + log::info "XDG_DATA_HOME=${XDG_DATA_HOME}" log::info "subuid: $(cat /etc/subuid 2> /dev/null || echo 'not found')" + log::info "Podman graphRoot: $(podman info --format '{{.Store.GraphRoot}}' 2>&1 || echo 'podman info failed')" bash "${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" "${prepare_args[@]}" \ || { @@ -106,6 +119,15 @@ EOF } log::success "Operator installed via prepare-restricted-environment.sh" + # prepare-restricted-environment.sh applies IDMS/CatalogSource which triggers + # a MachineConfig update and node rolling. Wait for completion before deploying + # workloads, same as the Helm path. + log::info "Waiting for MachineConfigPool updates to complete (up to 20m)..." + if ! oc wait machineconfigpool --all --for=condition=Updated=True --timeout=20m; then + log::warn "MachineConfigPool wait timed out — proceeding anyway" + fi + log::success "All MachineConfigPools are Updated" + k8s_wait::crd "backstages.rhdh.redhat.com" 300 10 || { log::error "Backstage CRD not available after operator installation" return 1 From 5c25616eb5825b3248daeb1a0f8fd58b88ab854b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Tue, 14 Jul 2026 16:21:35 +0200 Subject: [PATCH 21/45] fix(disconnected): use entrypoint overlay config instead of VFS override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nested-podman entrypoint detects /dev/fuse + fuse-overlayfs and creates an overlay storage config at /home/user/.config/containers/ storage.conf with graphroot=/tmp/graphroot. The VFS driver we were overriding to triggers chown on storage init which fails in the pod's user namespace. Root cause: commands.sh sets HOME=/tmp, so podman no longer finds the entrypoint's config at /home/user/.config/containers/. Our handler then wrote a custom VFS config, but VFS calls chown() on the graphroot directory tree during initialization, and chown is forbidden in the user namespace created by nested_podman: true (hostUsers: false). Fix: run prepare-restricted-environment.sh with HOME=/home/user so podman uses the entrypoint's overlay+fuse-overlayfs config naturally. This matches how other teams (e.g., MCO) use the nested-podman image in CI — no custom storage config needed. Removed: _CONTAINERS_USERNS_CONFIGURED, CONTAINERS_STORAGE_CONF, XDG_DATA_HOME, podman system reset, custom storage.conf. Kept: BUILDAH_ISOLATION=chroot (avoids nested userns for builds). Assisted-by: OpenCode --- .../jobs/ocp-disconnected-operator.sh | 80 ++++++------------- 1 file changed, 23 insertions(+), 57 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index 37830df527..ca0ed6e732 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -51,68 +51,34 @@ handle_ocp_disconnected_operator() { ) 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 - # And chowns the storage graphroot, which fails with: - # chown .../storage/vfs/dir: operation not permitted - # Fix: - # _CONTAINERS_USERNS_CONFIGURED=1 — skip newuidmap (userns already set up) - # BUILDAH_ISOLATION=chroot — chroot instead of user namespace for builds - # rootless_storage_path — the only field that getRootlessStorageOpts() - # checks before falling back to $HOME/.local/share/containers/storage; - # plain "graphroot" is overwritten by the rootless path logic - # XDG_DATA_HOME — controls homedir.GetDataHome() fallback in c/storage - # ignore_chown_errors — safety net for layer operations - export _CONTAINERS_USERNS_CONFIGURED=1 + # The CI pod runs with nested_podman: true, which provides: + # - hostUsers: false (Linux user namespace) + # - /dev/fuse device (via CRI-O annotation) + # - SETUID/SETGID capabilities (for newuidmap/newgidmap) + # The entrypoint.sh (from the nested-podman image) detects /dev/fuse + + # fuse-overlayfs and creates an overlay storage config at + # /home/user/.config/containers/storage.conf with: + # driver = "overlay", graphroot = "/tmp/graphroot", + # mount_program = "/usr/bin/fuse-overlayfs" + # + # commands.sh overrides HOME=/tmp, so podman no longer finds the + # entrypoint's config at /home/user/.config/containers/. Fix: run + # prepare-restricted-environment.sh with HOME=/home/user so podman + # uses the entrypoint's overlay config naturally — the same way the + # nested-podman image is used by other teams (e.g., MCO) in CI. + # + # BUILDAH_ISOLATION=chroot avoids creating a nested user namespace + # inside the already-active pod userns. export BUILDAH_ISOLATION=chroot - local podman_storage="${DISCONNECTED_TMPDIR}/podman-storage" - local podman_run="${DISCONNECTED_TMPDIR}/podman-run" - mkdir -p "${podman_storage}" "${podman_run}" - - # Point XDG_DATA_HOME to our tmpdir so the c/storage library's - # getRootlessStorageOpts() computes graphroot under our directory - # instead of defaulting to $HOME/.local/share/containers/storage. - export XDG_DATA_HOME="${DISCONNECTED_TMPDIR}/data" - mkdir -p "${XDG_DATA_HOME}" - - # Clear any pre-initialized storage state from the entrypoint BEFORE - # writing our config. podman system reset deletes $HOME/.config/containers/ - # in some versions, so we must run it first. - podman system reset --force 2> /dev/null || true - - # Write storage.conf with rootless_storage_path — the only field that - # containers/storage's getRootlessStorageOpts() respects for overriding - # the rootless graphroot. Plain "graphroot" is overwritten by rootless - # path logic (it falls back to $XDG_DATA_HOME/containers/storage). - local storage_conf="${DISCONNECTED_TMPDIR}/storage.conf" - cat > "${storage_conf}" << EOF -[storage] -driver = "vfs" -graphroot = "${podman_storage}" -rootless_storage_path = "${podman_storage}" -runroot = "${podman_run}" - -[storage.options] -ignore_chown_errors = "true" -EOF - export CONTAINERS_STORAGE_CONF="${storage_conf}" - - # Also install the config at the user-level path (HOME/.config/containers/). - # The prepare-restricted-environment.sh script invokes podman build which - # may read this path instead of CONTAINERS_STORAGE_CONF. - mkdir -p "${HOME}/.config/containers" - cp "${storage_conf}" "${HOME}/.config/containers/storage.conf" - + local entrypoint_home="/home/user" log::info "Podman environment: uid=$(id -u), BUILDAH_ISOLATION=${BUILDAH_ISOLATION}" - log::info "Storage config (${CONTAINERS_STORAGE_CONF}): $(tr '\n' ' ' < "${storage_conf}")" - log::info "XDG_DATA_HOME=${XDG_DATA_HOME}" + log::info "Entrypoint storage config: $(tr '\n' ' ' < "${entrypoint_home}/.config/containers/storage.conf" 2> /dev/null || echo 'not found')" log::info "subuid: $(cat /etc/subuid 2> /dev/null || echo 'not found')" - log::info "Podman graphRoot: $(podman info --format '{{.Store.GraphRoot}}' 2>&1 || echo 'podman info failed')" + log::info "fuse-overlayfs: $(command -v fuse-overlayfs 2> /dev/null || echo 'not found'), /dev/fuse: $(test -c /dev/fuse && echo 'present' || echo 'missing')" + log::info "Podman graphRoot: $(HOME=${entrypoint_home} podman info --format '{{.Store.GraphRoot}}' 2>&1 || echo 'podman info failed')" - bash "${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" "${prepare_args[@]}" \ + HOME=${entrypoint_home} bash "${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" "${prepare_args[@]}" \ || { log::error "prepare-restricted-environment.sh failed — aborting" return 1 From 7e4e26ae162021ece1c00c3a183da87ce8137ce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Tue, 14 Jul 2026 18:05:50 +0200 Subject: [PATCH 22/45] fix(disconnected): write overlay config at runtime HOME instead of VFS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entrypoint's storage config creation silently fails because /home/user is not writable by uid 1000 at entrypoint time — the passwd entry that grants gid 0 is created after the mkdir step. All previous attempts used the VFS driver, which calls chown() on the graphroot during storage initialization — forbidden in the pod's user namespace (hostUsers: false). The overlay driver with fuse-overlayfs does NOT chown the graphroot. Fix: write the same overlay+fuse-overlayfs config the entrypoint would have created (driver=overlay, graphroot=/tmp/graphroot, mount_program=/usr/bin/fuse-overlayfs) at the runtime HOME (/tmp, set by commands.sh). Both /dev/fuse and fuse-overlayfs are available in the CI pod (confirmed by previous run's debug output). Assisted-by: OpenCode --- .../jobs/ocp-disconnected-operator.sh | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index ca0ed6e732..5f43a48e92 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -55,30 +55,37 @@ handle_ocp_disconnected_operator() { # - hostUsers: false (Linux user namespace) # - /dev/fuse device (via CRI-O annotation) # - SETUID/SETGID capabilities (for newuidmap/newgidmap) - # The entrypoint.sh (from the nested-podman image) detects /dev/fuse + - # fuse-overlayfs and creates an overlay storage config at - # /home/user/.config/containers/storage.conf with: - # driver = "overlay", graphroot = "/tmp/graphroot", - # mount_program = "/usr/bin/fuse-overlayfs" + # The entrypoint.sh (from the nested-podman image) would normally create an + # overlay storage config at ${HOME}/.config/containers/storage.conf, but it + # silently fails because /home/user is not writable by uid 1000 at entrypoint + # time (the passwd entry that grants gid 0 is created after the mkdir). # - # commands.sh overrides HOME=/tmp, so podman no longer finds the - # entrypoint's config at /home/user/.config/containers/. Fix: run - # prepare-restricted-environment.sh with HOME=/home/user so podman - # uses the entrypoint's overlay config naturally — the same way the - # nested-podman image is used by other teams (e.g., MCO) in CI. + # Fix: write the same overlay+fuse-overlayfs config the entrypoint would have + # created, but at the runtime HOME (/tmp, set by commands.sh). This uses the + # overlay driver which does NOT chown the graphroot on init (unlike VFS which + # triggers "chown: operation not permitted" in the user namespace). # - # BUILDAH_ISOLATION=chroot avoids creating a nested user namespace - # inside the already-active pod userns. + # BUILDAH_ISOLATION=chroot avoids creating a nested user namespace inside the + # already-active pod userns. export BUILDAH_ISOLATION=chroot - local entrypoint_home="/home/user" + mkdir -p "${HOME}/.config/containers" + cat > "${HOME}/.config/containers/storage.conf" << 'EOF' +[storage] +driver = "overlay" +graphroot = "/tmp/graphroot" + +[storage.options.overlay] +mount_program = "/usr/bin/fuse-overlayfs" +EOF + log::info "Podman environment: uid=$(id -u), BUILDAH_ISOLATION=${BUILDAH_ISOLATION}" - log::info "Entrypoint storage config: $(tr '\n' ' ' < "${entrypoint_home}/.config/containers/storage.conf" 2> /dev/null || echo 'not found')" + log::info "Storage config (${HOME}/.config/containers/storage.conf): $(tr '\n' ' ' < "${HOME}/.config/containers/storage.conf")" log::info "subuid: $(cat /etc/subuid 2> /dev/null || echo 'not found')" log::info "fuse-overlayfs: $(command -v fuse-overlayfs 2> /dev/null || echo 'not found'), /dev/fuse: $(test -c /dev/fuse && echo 'present' || echo 'missing')" - log::info "Podman graphRoot: $(HOME=${entrypoint_home} podman info --format '{{.Store.GraphRoot}}' 2>&1 || echo 'podman info failed')" + log::info "Podman graphRoot: $(podman info --format '{{.Store.GraphRoot}}' 2>&1 || echo 'podman info failed')" - HOME=${entrypoint_home} bash "${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" "${prepare_args[@]}" \ + bash "${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" "${prepare_args[@]}" \ || { log::error "prepare-restricted-environment.sh failed — aborting" return 1 From 45d033f5bb99d318837de443fcd86b7369d32a05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Tue, 14 Jul 2026 19:30:52 +0200 Subject: [PATCH 23/45] fix(disconnected): use VFS with pre-created graphroot to avoid chown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both storage drivers fail in the CI pod's user namespace: - overlay+fuse-overlayfs needs newuidmap which fails (file capabilities from build time are not effective inside the pod's userns) - VFS doesn't need newuidmap but chowns the graphroot directory tree during storage initialization, which is forbidden in the userns Fix: use VFS with _CONTAINERS_USERNS_CONFIGURED=1 (skip newuidmap) and pre-create the graphroot directory tree (vfs/dir, vfs-images, vfs-layers, runroot/vfs, runroot/libpod) so that the store init's MkdirAllAndChown finds existing dirs owned by uid 1000 and skips the chown. BUILDAH_ISOLATION=chroot avoids nested userns for builds. ignore_chown_errors covers layer-level chown operations. Write the config at the runtime HOME (/tmp, set by commands.sh) at ${HOME}/.config/containers/storage.conf — the standard rootless path. Assisted-by: OpenCode --- .../jobs/ocp-disconnected-operator.sh | 51 +++++++++++-------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index 5f43a48e92..5a10f9836e 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -51,38 +51,47 @@ handle_ocp_disconnected_operator() { ) fi - # The CI pod runs with nested_podman: true, which provides: - # - hostUsers: false (Linux user namespace) - # - /dev/fuse device (via CRI-O annotation) - # - SETUID/SETGID capabilities (for newuidmap/newgidmap) - # The entrypoint.sh (from the nested-podman image) would normally create an - # overlay storage config at ${HOME}/.config/containers/storage.conf, but it - # silently fails because /home/user is not writable by uid 1000 at entrypoint - # time (the passwd entry that grants gid 0 is created after the mkdir). + # The CI pod runs with nested_podman: true (hostUsers: false), placing it + # inside a Linux user namespace. Two storage drivers were attempted: + # - overlay+fuse-overlayfs: needs newuidmap to create a nested userns, + # but newuidmap fails ("open of uid_map failed: Permission denied") + # because file capabilities set at build time are not effective inside + # the pod's user namespace. + # - VFS: doesn't need newuidmap, but chowns the graphroot on storage + # initialization, which fails in the userns. # - # Fix: write the same overlay+fuse-overlayfs config the entrypoint would have - # created, but at the runtime HOME (/tmp, set by commands.sh). This uses the - # overlay driver which does NOT chown the graphroot on init (unlike VFS which - # triggers "chown: operation not permitted" in the user namespace). - # - # BUILDAH_ISOLATION=chroot avoids creating a nested user namespace inside the - # already-active pod userns. + # Fix: use VFS with _CONTAINERS_USERNS_CONFIGURED=1 (skip newuidmap) and + # pre-create the graphroot directory tree so the store initialization's + # MkdirAllAndChown finds existing dirs and skips chown. BUILDAH_ISOLATION= + # chroot avoids creating a nested userns for builds. ignore_chown_errors + # covers layer-level chown operations. + export _CONTAINERS_USERNS_CONFIGURED=1 export BUILDAH_ISOLATION=chroot + local graphroot="/tmp/graphroot" + local runroot="/tmp/runroot" + + # Pre-create the directory tree that podman/c-storage expects. When the + # directories already exist and are owned by uid 1000, the store + # initialization skips chown (which would fail in the userns). + mkdir -p "${graphroot}/vfs/dir" "${graphroot}/vfs-images" "${graphroot}/vfs-layers" + mkdir -p "${runroot}/vfs" "${runroot}/libpod" + mkdir -p "${HOME}/.config/containers" - cat > "${HOME}/.config/containers/storage.conf" << 'EOF' + cat > "${HOME}/.config/containers/storage.conf" << EOF [storage] -driver = "overlay" -graphroot = "/tmp/graphroot" +driver = "vfs" +graphroot = "${graphroot}" +runroot = "${runroot}" -[storage.options.overlay] -mount_program = "/usr/bin/fuse-overlayfs" +[storage.options] +ignore_chown_errors = "true" EOF log::info "Podman environment: uid=$(id -u), BUILDAH_ISOLATION=${BUILDAH_ISOLATION}" log::info "Storage config (${HOME}/.config/containers/storage.conf): $(tr '\n' ' ' < "${HOME}/.config/containers/storage.conf")" log::info "subuid: $(cat /etc/subuid 2> /dev/null || echo 'not found')" - log::info "fuse-overlayfs: $(command -v fuse-overlayfs 2> /dev/null || echo 'not found'), /dev/fuse: $(test -c /dev/fuse && echo 'present' || echo 'missing')" + log::info "graphroot owner: $(ls -ld "${graphroot}/vfs/dir" 2> /dev/null || echo 'not found')" log::info "Podman graphRoot: $(podman info --format '{{.Store.GraphRoot}}' 2>&1 || echo 'podman info failed')" bash "${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" "${prepare_args[@]}" \ From 7b0befb094b84aa8fe22ad82c30b01a0f7a08ff7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Wed, 22 Jul 2026 10:44:31 +0200 Subject: [PATCH 24/45] fix(disconnected): use oc-mirror for operator prepare to avoid nested Podman Ubuntu Podman cannot initialize storage under hostUsers:false. Switch prepare-restricted-environment.sh to --use-oc-mirror true so catalog mirroring no longer requires podman build, and stop wiring the plugin catalog index as the OLM --index-image. Co-authored-by: Cursor --- .../jobs/ocp-disconnected-operator.sh | 77 ++++++------------- 1 file changed, 22 insertions(+), 55 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index 5a10f9836e..5c26226cd0 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -38,67 +38,34 @@ handle_ocp_disconnected_operator() { return 1 } + # Use oc-mirror (documented air-gapped OCP path) instead of the script's + # default skopeo/umoci/podman-build path. Nested Podman in this CI pod cannot + # initialize storage (newuidmap / VFS chown both fail under hostUsers: false). + # CATALOG_INDEX_IMAGE is the plugin catalog index — do not pass it as + # --index-image (OLM operator catalog). Keep it for mirror-plugins.sh below. local prepare_args=( + --use-oc-mirror true --to-registry "${MIRROR_REGISTRY_URL}" + --index-image "registry.redhat.io/redhat/redhat-operator-index:v4.21" --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}" - ) + + # oc-mirror panics when REGISTRY_AUTH_FILE is set (distribution/distribution + # treats it as storage driver config). Auth comes from + # ${XDG_RUNTIME_DIR}/containers/auth.json via disconnected::setup_auth. + local saved_registry_auth_file="${REGISTRY_AUTH_FILE:-}" + unset REGISTRY_AUTH_FILE + + log::info "Running prepare-restricted-environment.sh with: ${prepare_args[*]}" + if ! bash "${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" "${prepare_args[@]}"; then + [[ -n "${saved_registry_auth_file}" ]] && export REGISTRY_AUTH_FILE="${saved_registry_auth_file}" + log::error "prepare-restricted-environment.sh failed — aborting" + return 1 fi - # The CI pod runs with nested_podman: true (hostUsers: false), placing it - # inside a Linux user namespace. Two storage drivers were attempted: - # - overlay+fuse-overlayfs: needs newuidmap to create a nested userns, - # but newuidmap fails ("open of uid_map failed: Permission denied") - # because file capabilities set at build time are not effective inside - # the pod's user namespace. - # - VFS: doesn't need newuidmap, but chowns the graphroot on storage - # initialization, which fails in the userns. - # - # Fix: use VFS with _CONTAINERS_USERNS_CONFIGURED=1 (skip newuidmap) and - # pre-create the graphroot directory tree so the store initialization's - # MkdirAllAndChown finds existing dirs and skips chown. BUILDAH_ISOLATION= - # chroot avoids creating a nested userns for builds. ignore_chown_errors - # covers layer-level chown operations. - export _CONTAINERS_USERNS_CONFIGURED=1 - export BUILDAH_ISOLATION=chroot - - local graphroot="/tmp/graphroot" - local runroot="/tmp/runroot" - - # Pre-create the directory tree that podman/c-storage expects. When the - # directories already exist and are owned by uid 1000, the store - # initialization skips chown (which would fail in the userns). - mkdir -p "${graphroot}/vfs/dir" "${graphroot}/vfs-images" "${graphroot}/vfs-layers" - mkdir -p "${runroot}/vfs" "${runroot}/libpod" - - mkdir -p "${HOME}/.config/containers" - cat > "${HOME}/.config/containers/storage.conf" << EOF -[storage] -driver = "vfs" -graphroot = "${graphroot}" -runroot = "${runroot}" - -[storage.options] -ignore_chown_errors = "true" -EOF - - log::info "Podman environment: uid=$(id -u), BUILDAH_ISOLATION=${BUILDAH_ISOLATION}" - log::info "Storage config (${HOME}/.config/containers/storage.conf): $(tr '\n' ' ' < "${HOME}/.config/containers/storage.conf")" - log::info "subuid: $(cat /etc/subuid 2> /dev/null || echo 'not found')" - log::info "graphroot owner: $(ls -ld "${graphroot}/vfs/dir" 2> /dev/null || echo 'not found')" - log::info "Podman graphRoot: $(podman info --format '{{.Store.GraphRoot}}' 2>&1 || echo 'podman info failed')" - - bash "${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" "${prepare_args[@]}" \ - || { - log::error "prepare-restricted-environment.sh failed — aborting" - return 1 - } + if [[ -n "${saved_registry_auth_file}" ]]; then + export REGISTRY_AUTH_FILE="${saved_registry_auth_file}" + fi log::success "Operator installed via prepare-restricted-environment.sh" # prepare-restricted-environment.sh applies IDMS/CatalogSource which triggers From 9e23b867bcfe9108c5f200d06facf70df3d8b45f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Wed, 22 Jul 2026 12:18:05 +0200 Subject: [PATCH 25/45] fix(disconnected): force OLM v0 with oc-mirror prepare path OCP 4.21 auto-detects OLM v1, but prepare-restricted-environment.sh then fails applying a missing clusterCatalog.yaml after oc-mirror. Use --olm-version v0 (CatalogSource/Subscription) and map RELEASE_VERSION next to '*' for filter-versions. Co-authored-by: Cursor --- .ci/pipelines/jobs/ocp-disconnected-operator.sh | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index 5c26226cd0..4b74dbab51 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -43,11 +43,22 @@ handle_ocp_disconnected_operator() { # initialize storage (newuidmap / VFS chown both fail under hostUsers: false). # CATALOG_INDEX_IMAGE is the plugin catalog index — do not pass it as # --index-image (OLM operator catalog). Keep it for mirror-plugins.sh below. + # + # Force OLM v0: prepare-restricted-environment.sh on main auto-detects OLM v1 + # on OCP 4.21, but the oc-mirror + OLM v1 install path then applies a missing + # clusterCatalog.yaml (upstream bug). CatalogSource + Subscription (v0) works + # with oc-mirror's cs-*.yaml output. + local filter_versions="${RELEASE_VERSION}" + if [[ "${filter_versions}" == "next" || "${filter_versions}" == "*" ]]; then + filter_versions="*" + fi + local prepare_args=( --use-oc-mirror true + --olm-version v0 --to-registry "${MIRROR_REGISTRY_URL}" --index-image "registry.redhat.io/redhat/redhat-operator-index:v4.21" - --filter-versions "${RELEASE_VERSION}" + --filter-versions "${filter_versions}" ) # oc-mirror panics when REGISTRY_AUTH_FILE is set (distribution/distribution From 5b47e6a726fb0531008f1fb8028423fa190ec09e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Wed, 22 Jul 2026 13:53:44 +0200 Subject: [PATCH 26/45] fix(disconnected): use minimal Backstage CR for operator smoke The full rhdh-start.yaml references ConfigMaps/Secrets from the normal apply_yaml_files() flow. Use a guest-auth-only CR and force NAME_SPACE=showcase-disconnected so env_variables.sh cannot override it. --- .../jobs/ocp-disconnected-operator.sh | 15 +++++++-- .../app-config-rhdh-disconnected-smoke.yaml | 8 +++++ .../rhdh-start-disconnected-smoke.yaml | 33 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 .ci/pipelines/resources/config_map/app-config-rhdh-disconnected-smoke.yaml create mode 100644 .ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index 4b74dbab51..ff5e2c70c0 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -18,7 +18,8 @@ source "$DIR"/lib/disconnected.sh export INSTALL_METHOD="operator" handle_ocp_disconnected_operator() { - export NAME_SPACE="${NAME_SPACE:-showcase-disconnected}" + # Force a dedicated namespace (env_variables.sh may already set NAME_SPACE=showcase). + export NAME_SPACE="showcase-disconnected" disconnected::require_env disconnected::setup_auth @@ -129,8 +130,18 @@ handle_ocp_disconnected_operator() { log::section "Backstage CR Deployment" + # Minimal guest-auth ConfigMap (full rhdh-start.yaml references ConfigMaps/Secrets + # created by apply_yaml_files(), which this disconnected handler skips). + oc create configmap app-config-rhdh-disconnected-smoke \ + --from-file="app-config-rhdh.yaml=${DIR}/resources/config_map/app-config-rhdh-disconnected-smoke.yaml" \ + --namespace="${NAME_SPACE}" \ + --dry-run=client -o yaml | oc apply -f - || { + log::error "Failed to create app-config ConfigMap — aborting" + return 1 + } + local rendered_cr - rendered_cr=$(envsubst < "${DIR}/resources/rhdh-operator/rhdh-start.yaml") + rendered_cr=$(envsubst < "${DIR}/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml") rendered_cr=$(echo "$rendered_cr" | yq eval \ '.spec.application.extraFiles.configMaps = [ { diff --git a/.ci/pipelines/resources/config_map/app-config-rhdh-disconnected-smoke.yaml b/.ci/pipelines/resources/config_map/app-config-rhdh-disconnected-smoke.yaml new file mode 100644 index 0000000000..a2ec462312 --- /dev/null +++ b/.ci/pipelines/resources/config_map/app-config-rhdh-disconnected-smoke.yaml @@ -0,0 +1,8 @@ +# Minimal app-config for disconnected operator smoke (guest login only). +app: + title: Red Hat Developer Hub +auth: + environment: development + providers: + guest: + dangerouslyAllowOutsideDevelopment: true diff --git a/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml b/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml new file mode 100644 index 0000000000..11d6139de0 --- /dev/null +++ b/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml @@ -0,0 +1,33 @@ +# Minimal Backstage CR for disconnected operator smoke test. +# Smoke only needs: guest login → homepage. Avoid ConfigMaps/Secrets that the +# normal apply_yaml_files() flow creates (app-config-rhdh, dynamic-plugins-*, +# rhdh-secrets, redis-secret). +kind: Backstage +apiVersion: rhdh.redhat.com/v1alpha5 +metadata: + name: rhdh +spec: + deployment: + patch: + spec: + template: + spec: + containers: + - name: backstage-backend + image: "$IMAGE_REGISTRY/$IMAGE_REPO:$TAG_NAME" + initContainers: + - name: install-dynamic-plugins + image: "$IMAGE_REGISTRY/$IMAGE_REPO:$TAG_NAME" + application: + appConfig: + configMaps: + - name: app-config-rhdh-disconnected-smoke + mountPath: /opt/app-root/src + extraEnvs: + envs: + - name: NODE_OPTIONS + value: "--no-node-snapshot" + - name: NODE_ENV + value: "production" + - name: NODE_TLS_REJECT_UNAUTHORIZED + value: "0" From 2fa96d60521062600291e967cccfc3649b4988a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Wed, 22 Jul 2026 13:57:40 +0200 Subject: [PATCH 27/45] fix(disconnected): derive operator index image from cluster OCP version Avoid hardcoding redhat-operator-index:v4.21 so the job matches the live cluster major.minor (same oc version pattern as container-init.sh). --- .../jobs/ocp-disconnected-operator.sh | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index ff5e2c70c0..aba3687c98 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -46,19 +46,30 @@ handle_ocp_disconnected_operator() { # --index-image (OLM operator catalog). Keep it for mirror-plugins.sh below. # # Force OLM v0: prepare-restricted-environment.sh on main auto-detects OLM v1 - # on OCP 4.21, but the oc-mirror + OLM v1 install path then applies a missing - # clusterCatalog.yaml (upstream bug). CatalogSource + Subscription (v0) works - # with oc-mirror's cs-*.yaml output. + # on newer OCP (e.g. 4.21), but the oc-mirror + OLM v1 install path then + # applies a missing clusterCatalog.yaml (upstream bug). CatalogSource + + # Subscription (v0) works with oc-mirror's cs-*.yaml output. local filter_versions="${RELEASE_VERSION}" if [[ "${filter_versions}" == "next" || "${filter_versions}" == "*" ]]; then filter_versions="*" fi + # Match redhat-operator-index major.minor to the live cluster (same pattern as + # e2e-tests/container-init.sh CONTAINER_PLATFORM_VERSION). + local ocp_version + ocp_version=$(oc version --output json 2> /dev/null | jq -r '.openshiftVersion' | cut -d'.' -f1,2) + if [[ -z "${ocp_version}" || "${ocp_version}" == "null" || ! "${ocp_version}" =~ ^[0-9]+\.[0-9]+$ ]]; then + log::error "Failed to detect OpenShift major.minor version from 'oc version'" + return 1 + fi + local index_image="registry.redhat.io/redhat/redhat-operator-index:v${ocp_version}" + log::info "Detected OCP ${ocp_version}; using index image ${index_image}" + local prepare_args=( --use-oc-mirror true --olm-version v0 --to-registry "${MIRROR_REGISTRY_URL}" - --index-image "registry.redhat.io/redhat/redhat-operator-index:v4.21" + --index-image "${index_image}" --filter-versions "${filter_versions}" ) From 9954c094a04031b8d3827c9e6288c16d32d77411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Wed, 22 Jul 2026 13:58:24 +0200 Subject: [PATCH 28/45] fix(disconnected): pin prepare script for OLM v1 oc-mirror path Temporarily fetch prepare-restricted-environment.sh from rhdh-operator PR #3259 so oc-mirror + OLM v1 can skip the missing clusterCatalog.yaml on main, and drop the --olm-version v0 workaround. --- .../jobs/ocp-disconnected-operator.sh | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index aba3687c98..eaa19b0e71 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -33,11 +33,21 @@ handle_ocp_disconnected_operator() { # 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 - } + # TEMPORARY: pin prepare-restricted-environment.sh to rhdh-operator PR #3259 + # (https://github.com/redhat-developer/rhdh-operator/pull/3259) so oc-mirror + # + OLM v1 uses native cc-*.yaml catalogs and skips re-applying a synthetic + # clusterCatalog.yaml that is missing on main. Revert to + # disconnected::fetch_script once that PR merges. + local prepare_script_sha="0f0db074a3504bf61ff2cfbbc90a8089c4723822" + local prepare_script_path="${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" + local prepare_script_url="https://raw.githubusercontent.com/redhat-developer/rhdh-operator/${prepare_script_sha}/.rhdh/scripts/prepare-restricted-environment.sh" + log::info "Fetching prepare-restricted-environment.sh from rhdh-operator@${prepare_script_sha} (temporary pin for PR #3259)..." + if ! curl -fL --max-time 30 -o "${prepare_script_path}" "${prepare_script_url}"; then + log::error "Failed to download prepare-restricted-environment.sh from ${prepare_script_url}" + return 1 + fi + chmod +x "${prepare_script_path}" + log::success "Downloaded prepare-restricted-environment.sh to ${prepare_script_path}" # Use oc-mirror (documented air-gapped OCP path) instead of the script's # default skopeo/umoci/podman-build path. Nested Podman in this CI pod cannot @@ -45,10 +55,8 @@ handle_ocp_disconnected_operator() { # CATALOG_INDEX_IMAGE is the plugin catalog index — do not pass it as # --index-image (OLM operator catalog). Keep it for mirror-plugins.sh below. # - # Force OLM v0: prepare-restricted-environment.sh on main auto-detects OLM v1 - # on newer OCP (e.g. 4.21), but the oc-mirror + OLM v1 install path then - # applies a missing clusterCatalog.yaml (upstream bug). CatalogSource + - # Subscription (v0) works with oc-mirror's cs-*.yaml output. + # OLM version: leave default (auto). On OCP 4.21+ this selects OLM v1; + # the temporary prepare-script pin above fixes the oc-mirror + v1 path. local filter_versions="${RELEASE_VERSION}" if [[ "${filter_versions}" == "next" || "${filter_versions}" == "*" ]]; then filter_versions="*" @@ -67,7 +75,6 @@ handle_ocp_disconnected_operator() { local prepare_args=( --use-oc-mirror true - --olm-version v0 --to-registry "${MIRROR_REGISTRY_URL}" --index-image "${index_image}" --filter-versions "${filter_versions}" @@ -80,7 +87,7 @@ handle_ocp_disconnected_operator() { unset REGISTRY_AUTH_FILE log::info "Running prepare-restricted-environment.sh with: ${prepare_args[*]}" - if ! bash "${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" "${prepare_args[@]}"; then + if ! bash "${prepare_script_path}" "${prepare_args[@]}"; then [[ -n "${saved_registry_auth_file}" ]] && export REGISTRY_AUTH_FILE="${saved_registry_auth_file}" log::error "prepare-restricted-environment.sh failed — aborting" return 1 From 7929a6d3b60667509dbf8586caafdbaaeae4d61f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Wed, 22 Jul 2026 14:15:53 +0200 Subject: [PATCH 29/45] refactor(disconnected): share MCP, plugin-mirror, and auth helpers Extract duplicated Helm/Operator post-mirror steps into disconnected.sh so both handlers call the same helpers without changing deploy behavior. --- .ci/pipelines/jobs/ocp-disconnected-helm.sh | 35 +----- .../jobs/ocp-disconnected-operator.sh | 63 ++-------- .ci/pipelines/lib/disconnected.sh | 108 ++++++++++++++---- 3 files changed, 101 insertions(+), 105 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-helm.sh b/.ci/pipelines/jobs/ocp-disconnected-helm.sh index 783dfc9607..9de10bbb7f 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-helm.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-helm.sh @@ -138,44 +138,15 @@ handle_ocp_disconnected_helm() { # IDMS/ITMS changes trigger a MachineConfig update which rolls worker nodes # (drain → apply config → reboot). Wait for all MachineConfigPools to finish # before deploying workloads, otherwise pods get evicted mid-startup. - log::info "Waiting for MachineConfigPool updates to complete (up to 20m)..." - if ! oc wait machineconfigpool --all --for=condition=Updated=True --timeout=20m; then - log::warn "MachineConfigPool wait timed out — proceeding anyway" - fi - log::success "All MachineConfigPools are Updated" + disconnected::wait_mcp_updated 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 - } + disconnected::mirror_plugins || 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 + disconnected::apply_plugin_mirror_configmap "${NAME_SPACE}" || return 1 # Mirror registry CA — mounted at /etc/containers/certs.d//ca.crt # inside the init container so skopeo trusts the mirror when IDMS redirects diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index eaa19b0e71..0e620eb2ab 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -37,17 +37,14 @@ handle_ocp_disconnected_operator() { # (https://github.com/redhat-developer/rhdh-operator/pull/3259) so oc-mirror # + OLM v1 uses native cc-*.yaml catalogs and skips re-applying a synthetic # clusterCatalog.yaml that is missing on main. Revert to - # disconnected::fetch_script once that PR merges. + # disconnected::fetch_script (branch default) once that PR merges. local prepare_script_sha="0f0db074a3504bf61ff2cfbbc90a8089c4723822" local prepare_script_path="${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" - local prepare_script_url="https://raw.githubusercontent.com/redhat-developer/rhdh-operator/${prepare_script_sha}/.rhdh/scripts/prepare-restricted-environment.sh" - log::info "Fetching prepare-restricted-environment.sh from rhdh-operator@${prepare_script_sha} (temporary pin for PR #3259)..." - if ! curl -fL --max-time 30 -o "${prepare_script_path}" "${prepare_script_url}"; then - log::error "Failed to download prepare-restricted-environment.sh from ${prepare_script_url}" + disconnected::fetch_script "prepare-restricted-environment.sh" \ + "${prepare_script_path}" "${prepare_script_sha}" || { + log::error "Failed to fetch prepare-restricted-environment.sh — aborting" return 1 - fi - chmod +x "${prepare_script_path}" - log::success "Downloaded prepare-restricted-environment.sh to ${prepare_script_path}" + } # Use oc-mirror (documented air-gapped OCP path) instead of the script's # default skopeo/umoci/podman-build path. Nested Podman in this CI pod cannot @@ -80,32 +77,18 @@ handle_ocp_disconnected_operator() { --filter-versions "${filter_versions}" ) - # oc-mirror panics when REGISTRY_AUTH_FILE is set (distribution/distribution - # treats it as storage driver config). Auth comes from - # ${XDG_RUNTIME_DIR}/containers/auth.json via disconnected::setup_auth. - local saved_registry_auth_file="${REGISTRY_AUTH_FILE:-}" - unset REGISTRY_AUTH_FILE - log::info "Running prepare-restricted-environment.sh with: ${prepare_args[*]}" - if ! bash "${prepare_script_path}" "${prepare_args[@]}"; then - [[ -n "${saved_registry_auth_file}" ]] && export REGISTRY_AUTH_FILE="${saved_registry_auth_file}" + if ! disconnected::with_unset_registry_auth_file \ + bash "${prepare_script_path}" "${prepare_args[@]}"; then log::error "prepare-restricted-environment.sh failed — aborting" return 1 fi - - if [[ -n "${saved_registry_auth_file}" ]]; then - export REGISTRY_AUTH_FILE="${saved_registry_auth_file}" - fi log::success "Operator installed via prepare-restricted-environment.sh" # prepare-restricted-environment.sh applies IDMS/CatalogSource which triggers # a MachineConfig update and node rolling. Wait for completion before deploying # workloads, same as the Helm path. - log::info "Waiting for MachineConfigPool updates to complete (up to 20m)..." - if ! oc wait machineconfigpool --all --for=condition=Updated=True --timeout=20m; then - log::warn "MachineConfigPool wait timed out — proceeding anyway" - fi - log::success "All MachineConfigPools are Updated" + disconnected::wait_mcp_updated k8s_wait::crd "backstages.rhdh.redhat.com" 300 10 || { log::error "Backstage CRD not available after operator installation" @@ -113,38 +96,12 @@ handle_ocp_disconnected_operator() { } 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 - } + disconnected::mirror_plugins || 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 + disconnected::apply_plugin_mirror_configmap "${NAME_SPACE}" || return 1 log::section "Backstage CR Deployment" diff --git a/.ci/pipelines/lib/disconnected.sh b/.ci/pipelines/lib/disconnected.sh index 24dcd3f5c5..c5ec9fd6c6 100644 --- a/.ci/pipelines/lib/disconnected.sh +++ b/.ci/pipelines/lib/disconnected.sh @@ -119,6 +119,24 @@ EOF cp "${output_path}" "${ARTIFACT_DIR}/disconnected-imageset-config.yaml" 2> /dev/null || true } +# Run a command with REGISTRY_AUTH_FILE unset, then restore it. +# oc-mirror (and prepare paths that invoke it) panic when REGISTRY_AUTH_FILE +# is set because distribution/distribution treats it as a storage driver +# config. Auth still comes from ${XDG_RUNTIME_DIR}/containers/auth.json +# (configured by disconnected::setup_auth). +disconnected::with_unset_registry_auth_file() { + local saved_registry_auth_file="${REGISTRY_AUTH_FILE:-}" + unset REGISTRY_AUTH_FILE + + local rc=0 + "$@" || rc=$? + + if [[ -n "${saved_registry_auth_file}" ]]; then + export REGISTRY_AUTH_FILE="${saved_registry_auth_file}" + fi + return "${rc}" +} + # 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: @@ -130,31 +148,17 @@ disconnected::run_oc_mirror() { 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 \ + if ! disconnected::with_unset_registry_auth_file 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) @@ -239,15 +243,24 @@ disconnected::patch_idms() { # 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) +# $3 - ref: (optional) Branch name or 40-char commit SHA +# (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}" + local ref="${3:-${RELEASE_BRANCH_NAME}}" + local url + local ref_label + + if [[ "${ref}" =~ ^[0-9a-f]{40}$ ]]; then + url="https://raw.githubusercontent.com/redhat-developer/rhdh-operator/${ref}/.rhdh/scripts/${script_name}" + ref_label="sha: ${ref}" + else + url="https://raw.githubusercontent.com/redhat-developer/rhdh-operator/refs/heads/${ref}/.rhdh/scripts/${script_name}" + ref_label="branch: ${ref}" + fi - log::info "Fetching ${script_name} from rhdh-operator (branch: ${branch})..." + log::info "Fetching ${script_name} from rhdh-operator (${ref_label})..." if ! curl -fL --max-time 30 -o "${output_path}" "${url}"; then log::error "Failed to download ${script_name} from ${url}" return 1 @@ -256,7 +269,62 @@ disconnected::fetch_script() { log::success "Downloaded ${script_name} to ${output_path}" } +# Wait for MachineConfigPool updates after IDMS/CatalogSource changes. +# Warns and continues on timeout (same behavior as both handlers historically). +disconnected::wait_mcp_updated() { + log::info "Waiting for MachineConfigPool updates to complete (up to 20m)..." + if ! oc wait machineconfigpool --all --for=condition=Updated=True --timeout=20m; then + log::warn "MachineConfigPool wait timed out — proceeding anyway" + fi + log::success "All MachineConfigPools are Updated" +} + +# Fetch and run mirror-plugins.sh against the disconnected mirror registry. +# Uses CATALOG_INDEX_IMAGE when set; otherwise the GA plugin-catalog-index tag. +disconnected::mirror_plugins() { + local mirror_script="${DISCONNECTED_TMPDIR}/mirror-plugins.sh" + + disconnected::fetch_script "mirror-plugins.sh" "${mirror_script}" || { + 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 "${mirror_script}" \ + --plugin-index "${plugin_index}" \ + --to-registry "${MIRROR_REGISTRY_URL}" || { + log::error "mirror-plugins.sh failed — aborting" + return 1 + } +} + +# Apply the shared plugin-mirror registries.conf ConfigMap in a namespace. +# Args: +# $1 - namespace +disconnected::apply_plugin_mirror_configmap() { + local namespace=$1 + local configmap_template="${DIR}/resources/disconnected/plugin-mirror-configmap.yaml" + + envsubst < "${configmap_template}" \ + | oc apply -n "${namespace}" -f - || { + log::error "Failed to create registries.conf ConfigMap — aborting" + return 1 + } + log::success "ConfigMap rhdh-plugin-mirror-conf created in ${namespace}" + + envsubst < "${configmap_template}" \ + > "${ARTIFACT_DIR}/disconnected-plugin-mirror-configmap.yaml" 2> /dev/null || true +} + # 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 +export -f disconnected::with_unset_registry_auth_file +export -f disconnected::wait_mcp_updated +export -f disconnected::mirror_plugins +export -f disconnected::apply_plugin_mirror_configmap From cc906716e85397b33b74ed782a8a6b481746f70b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Wed, 22 Jul 2026 14:21:05 +0200 Subject: [PATCH 30/45] fix(disconnected): reuse CONTAINER_PLATFORM_VERSION for OLM index Use the cluster major.minor already exported by container-init.sh instead of re-querying oc version in the disconnected operator handler. --- .ci/pipelines/jobs/ocp-disconnected-operator.sh | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index 0e620eb2ab..8eeb1f4551 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -59,16 +59,15 @@ handle_ocp_disconnected_operator() { filter_versions="*" fi - # Match redhat-operator-index major.minor to the live cluster (same pattern as - # e2e-tests/container-init.sh CONTAINER_PLATFORM_VERSION). - local ocp_version - ocp_version=$(oc version --output json 2> /dev/null | jq -r '.openshiftVersion' | cut -d'.' -f1,2) - if [[ -z "${ocp_version}" || "${ocp_version}" == "null" || ! "${ocp_version}" =~ ^[0-9]+\.[0-9]+$ ]]; then - log::error "Failed to detect OpenShift major.minor version from 'oc version'" + # CONTAINER_PLATFORM_VERSION is set by e2e-tests/container-init.sh from the + # live cluster (oc version → major.minor). + local ocp_version="${CONTAINER_PLATFORM_VERSION:-}" + if [[ -z "${ocp_version}" || "${ocp_version}" == "unknown" || ! "${ocp_version}" =~ ^[0-9]+\.[0-9]+$ ]]; then + log::error "CONTAINER_PLATFORM_VERSION is unset or invalid ('${ocp_version}'); expected OpenShift major.minor" return 1 fi local index_image="registry.redhat.io/redhat/redhat-operator-index:v${ocp_version}" - log::info "Detected OCP ${ocp_version}; using index image ${index_image}" + log::info "Using OCP ${ocp_version} (CONTAINER_PLATFORM_VERSION); index image ${index_image}" local prepare_args=( --use-oc-mirror true From 404b4794ca39c9307c8814c09754c0aff0c85efa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Wed, 22 Jul 2026 18:42:05 +0200 Subject: [PATCH 31/45] fix(disconnected): help OLM v1 pull mirrored operator images Merge mirror credentials into the cluster pull-secret, attach a real reg-pull-secret to the installer SA, and wait on ClusterExtension with status dumps when the Backstage CRD never appears. --- .../jobs/ocp-disconnected-operator.sh | 26 +++++- .ci/pipelines/lib/disconnected.sh | 92 +++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index 8eeb1f4551..4ed3ec580f 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -76,6 +76,10 @@ handle_ocp_disconnected_operator() { --filter-versions "${filter_versions}" ) + # prepare-restricted-environment.sh skips OLM v1 pull-secret setup for external + # registries; merge mirror credentials so catalogd can pull the ClusterCatalog. + disconnected::ensure_olm_mirror_pull_secret || return 1 + log::info "Running prepare-restricted-environment.sh with: ${prepare_args[*]}" if ! disconnected::with_unset_registry_auth_file \ bash "${prepare_script_path}" "${prepare_args[@]}"; then @@ -84,12 +88,32 @@ handle_ocp_disconnected_operator() { fi log::success "Operator installed via prepare-restricted-environment.sh" + # prepare patches the operator SA with internal-registry secret names that do + # not exist for an external mirror. Provide a real mirror pull secret and + # attach it to the OLM v1 installer SA used by ClusterExtension. + local operator_ns="rhdh-operator" + oc create secret generic reg-pull-secret \ + --from-file=.dockerconfigjson="${MIRROR_REGISTRY_PULL_SECRET}" \ + --type=kubernetes.io/dockerconfigjson \ + -n "${operator_ns}" \ + --dry-run=client -o yaml | oc apply -f - || { + log::error "Failed to create reg-pull-secret in ${operator_ns}" + return 1 + } + oc patch serviceaccount rhdh-operator-installer -n "${operator_ns}" --type=merge \ + -p '{"imagePullSecrets":[{"name":"reg-pull-secret"}]}' || { + log::warn "Failed to patch rhdh-operator-installer imagePullSecrets — continuing" + } + log::success "Configured mirror pull secret on rhdh-operator-installer SA" + # prepare-restricted-environment.sh applies IDMS/CatalogSource which triggers # a MachineConfig update and node rolling. Wait for completion before deploying # workloads, same as the Helm path. disconnected::wait_mcp_updated - k8s_wait::crd "backstages.rhdh.redhat.com" 300 10 || { + # prepare only creates the ClusterExtension; wait until OLM v1 installs the + # operator and the Backstage CRD appears (dump status on timeout). + disconnected::wait_operator_crd_olm_v1 "rhdh-operator" "backstages.rhdh.redhat.com" 600 || { log::error "Backstage CRD not available after operator installation" return 1 } diff --git a/.ci/pipelines/lib/disconnected.sh b/.ci/pipelines/lib/disconnected.sh index c5ec9fd6c6..ff9f13d909 100644 --- a/.ci/pipelines/lib/disconnected.sh +++ b/.ci/pipelines/lib/disconnected.sh @@ -320,6 +320,95 @@ disconnected::apply_plugin_mirror_configmap() { > "${ARTIFACT_DIR}/disconnected-plugin-mirror-configmap.yaml" 2> /dev/null || true } +# Merge mirror-registry credentials into openshift-config/pull-secret so OLM v1 +# catalogd/operator-controller can pull mirrored catalog/bundle images. +# prepare-restricted-environment.sh skips this for external registries. +disconnected::ensure_olm_mirror_pull_secret() { + local existing mirror_auth merged + + existing=$(oc get secret pull-secret -n openshift-config -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d) || { + log::error "Failed to read openshift-config/pull-secret" + return 1 + } + mirror_auth=$(cat "${MIRROR_REGISTRY_PULL_SECRET}") || { + log::error "Failed to read ${MIRROR_REGISTRY_PULL_SECRET}" + return 1 + } + + merged=$(jq -n --argjson existing "${existing}" --argjson mirror "${mirror_auth}" \ + '{auths: ($existing.auths + $mirror.auths)}') || { + log::error "Failed to merge mirror credentials into pull-secret JSON" + return 1 + } + + echo "${merged}" | oc set data secret/pull-secret -n openshift-config \ + --from-file=.dockerconfigjson=/dev/stdin || { + log::error "Failed to update openshift-config/pull-secret with mirror credentials" + return 1 + } + log::success "Merged mirror registry credentials into openshift-config/pull-secret" +} + +# Dump OLM v1 install status for debugging when the operator CRD never appears. +disconnected::dump_olm_v1_status() { + local extension_name=${1:-rhdh-operator} + local catalog_name=${2:-rhdh-catalog} + local operator_ns=${3:-rhdh-operator} + + log::info "Dumping OLM v1 status (ClusterCatalog/ClusterExtension/pods)..." + oc get clustercatalog "${catalog_name}" -o yaml > "${ARTIFACT_DIR}/disconnected-clustercatalog.yaml" 2> /dev/null || true + oc get clusterextension "${extension_name}" -o yaml > "${ARTIFACT_DIR}/disconnected-clusterextension.yaml" 2> /dev/null || true + oc get clustercatalog "${catalog_name}" -o wide 2>&1 | tee -a "${ARTIFACT_DIR}/disconnected-olm-v1-status.txt" || true + oc get clusterextension "${extension_name}" -o wide 2>&1 | tee -a "${ARTIFACT_DIR}/disconnected-olm-v1-status.txt" || true + oc describe clusterextension "${extension_name}" 2>&1 | tee -a "${ARTIFACT_DIR}/disconnected-olm-v1-status.txt" || true + oc get pods -n "${operator_ns}" -o wide 2>&1 | tee -a "${ARTIFACT_DIR}/disconnected-olm-v1-status.txt" || true + oc get events -n "${operator_ns}" --sort-by='.lastTimestamp' 2>&1 | tail -50 \ + | tee -a "${ARTIFACT_DIR}/disconnected-olm-v1-status.txt" || true +} + +# Wait for OLM v1 ClusterExtension to report Installed, then for the CRD. +# Args: +# $1 - extension name (default: rhdh-operator) +# $2 - crd name (default: backstages.rhdh.redhat.com) +# $3 - timeout seconds (default: 600) +disconnected::wait_operator_crd_olm_v1() { + local extension_name=${1:-rhdh-operator} + local crd_name=${2:-backstages.rhdh.redhat.com} + local timeout=${3:-600} + local interval=15 + local elapsed=0 + + log::info "Waiting for ClusterExtension/${extension_name} and CRD ${crd_name} (timeout: ${timeout}s)..." + + while ((elapsed < timeout)); do + if oc get crd "${crd_name}" > /dev/null 2>&1; then + log::success "CRD '${crd_name}' is available" + return 0 + fi + + local installed + installed=$(oc get clusterextension "${extension_name}" \ + -o jsonpath='{range .status.conditions[?(@.type=="Installed")]}{.status}{end}' 2> /dev/null || true) + if [[ "${installed}" == "True" ]]; then + log::info "ClusterExtension/${extension_name} reports Installed=True; waiting for CRD..." + else + local progressing reason + progressing=$(oc get clusterextension "${extension_name}" \ + -o jsonpath='{range .status.conditions[?(@.type=="Progressing")]}{.status}{end}' 2> /dev/null || true) + reason=$(oc get clusterextension "${extension_name}" \ + -o jsonpath='{range .status.conditions[?(@.type=="Installed")]}{.reason}{" "}{.message}{end}' 2> /dev/null || true) + log::debug "ClusterExtension Installed=${installed:-unknown} Progressing=${progressing:-unknown} ${reason}" + fi + + sleep "${interval}" + elapsed=$((elapsed + interval)) + done + + log::error "Timeout waiting for CRD '${crd_name}' after ${timeout}s" + disconnected::dump_olm_v1_status "${extension_name}" + return 1 +} + # Export functions for subshell usage (e.g., timeout bash -c "...") export -f disconnected::require_env export -f disconnected::setup_auth @@ -328,3 +417,6 @@ export -f disconnected::with_unset_registry_auth_file export -f disconnected::wait_mcp_updated export -f disconnected::mirror_plugins export -f disconnected::apply_plugin_mirror_configmap +export -f disconnected::ensure_olm_mirror_pull_secret +export -f disconnected::dump_olm_v1_status +export -f disconnected::wait_operator_crd_olm_v1 From c8b4a507b05c63e9c001d4a95b2d5feb6b1df394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Wed, 22 Jul 2026 21:26:58 +0200 Subject: [PATCH 32/45] fix(disconnected): trust mirror CA for OLM v1 catalogd pulls ClusterCatalog was stuck not Serving with x509 unknown authority. Add the mirror registry CA to image.config additionalTrustedCA and wait for MCP before running prepare. --- .../jobs/ocp-disconnected-operator.sh | 7 ++- .ci/pipelines/lib/disconnected.sh | 46 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index 4ed3ec580f..40dcf9a975 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -76,9 +76,12 @@ handle_ocp_disconnected_operator() { --filter-versions "${filter_versions}" ) - # prepare-restricted-environment.sh skips OLM v1 pull-secret setup for external - # registries; merge mirror credentials so catalogd can pull the ClusterCatalog. + # prepare-restricted-environment.sh skips OLM v1 pull-secret/CA setup for + # external registries. Catalogd must trust the mirror CA and authenticate + # before ClusterCatalog can reach Serving=True. disconnected::ensure_olm_mirror_pull_secret || return 1 + disconnected::ensure_mirror_registry_ca || return 1 + disconnected::wait_mcp_updated log::info "Running prepare-restricted-environment.sh with: ${prepare_args[*]}" if ! disconnected::with_unset_registry_auth_file \ diff --git a/.ci/pipelines/lib/disconnected.sh b/.ci/pipelines/lib/disconnected.sh index ff9f13d909..5145daa874 100644 --- a/.ci/pipelines/lib/disconnected.sh +++ b/.ci/pipelines/lib/disconnected.sh @@ -320,6 +320,51 @@ disconnected::apply_plugin_mirror_configmap() { > "${ARTIFACT_DIR}/disconnected-plugin-mirror-configmap.yaml" 2> /dev/null || true } +# Ensure the mirror registry CA is trusted cluster-wide via +# image.config.openshift.io/cluster additionalTrustedCA so OLM v1 catalogd +# can pull ClusterCatalog images (x509 unknown authority otherwise). +# Triggers an MCP update; callers should run disconnected::wait_mcp_updated after. +disconnected::ensure_mirror_registry_ca() { + local registry_host="${MIRROR_REGISTRY_URL%%/*}" + # OpenShift ConfigMap keys use ".." in place of ":" for host:port. + local cm_key="${registry_host//:/..}" + local cm_name="rhdh-disconnected-mirror-ca" + local existing_cm + + if [[ ! -f "${MIRROR_REGISTRY_CA}" ]]; then + log::error "MIRROR_REGISTRY_CA file not found: ${MIRROR_REGISTRY_CA}" + return 1 + fi + + existing_cm=$(oc get image.config.openshift.io/cluster -o jsonpath='{.spec.additionalTrustedCA.name}' 2> /dev/null || true) + if [[ -n "${existing_cm}" ]]; then + cm_name="${existing_cm}" + log::info "Merging mirror CA into existing additionalTrustedCA ConfigMap ${cm_name}" + oc get configmap "${cm_name}" -n openshift-config -o json \ + | jq --arg key "${cm_key}" --rawfile cert "${MIRROR_REGISTRY_CA}" \ + '.data[$key] = $cert | del(.metadata.resourceVersion, .metadata.uid, .metadata.creationTimestamp)' \ + | oc apply -f - || { + log::error "Failed to merge mirror CA into ConfigMap ${cm_name}" + return 1 + } + else + log::info "Creating additionalTrustedCA ConfigMap ${cm_name} for ${registry_host}" + oc create configmap "${cm_name}" -n openshift-config \ + --from-file="${cm_key}=${MIRROR_REGISTRY_CA}" \ + --dry-run=client -o yaml | oc apply -f - || { + log::error "Failed to create ConfigMap ${cm_name}" + return 1 + } + oc patch image.config.openshift.io/cluster --type=merge \ + -p "{\"spec\":{\"additionalTrustedCA\":{\"name\":\"${cm_name}\"}}}" || { + log::error "Failed to patch image.config.openshift.io/cluster additionalTrustedCA" + return 1 + } + fi + + log::success "Mirror CA trusted for ${registry_host} via ${cm_name}/${cm_key}" +} + # Merge mirror-registry credentials into openshift-config/pull-secret so OLM v1 # catalogd/operator-controller can pull mirrored catalog/bundle images. # prepare-restricted-environment.sh skips this for external registries. @@ -417,6 +462,7 @@ export -f disconnected::with_unset_registry_auth_file export -f disconnected::wait_mcp_updated export -f disconnected::mirror_plugins export -f disconnected::apply_plugin_mirror_configmap +export -f disconnected::ensure_mirror_registry_ca export -f disconnected::ensure_olm_mirror_pull_secret export -f disconnected::dump_olm_v1_status export -f disconnected::wait_operator_crd_olm_v1 From 6c9b4dea9639d0b04934478ab5decf1ecd6a8704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Thu, 23 Jul 2026 09:10:55 +0200 Subject: [PATCH 33/45] fix(disconnected): mount mirror CA for operator plugin installs install-dynamic-plugins failed with x509 on the mirrored catalog index because the operator CR only mounted registries.conf. Match Helm: mount mirror CA, policy.json, and registry auth for the init container. --- .ci/pipelines/jobs/ocp-disconnected-helm.sh | 30 +++---------- .../jobs/ocp-disconnected-operator.sh | 35 +++++++++++++++- .ci/pipelines/lib/disconnected.sh | 42 +++++++++++++++++++ 3 files changed, 81 insertions(+), 26 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-helm.sh b/.ci/pipelines/jobs/ocp-disconnected-helm.sh index 9de10bbb7f..768a1916e4 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-helm.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-helm.sh @@ -148,30 +148,12 @@ handle_ocp_disconnected_helm() { namespace::configure "${NAME_SPACE}" disconnected::apply_plugin_mirror_configmap "${NAME_SPACE}" || return 1 - # 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}" + # Mirror CA + registry auth for install-dynamic-plugins (skopeo). + # Post-renderer mounts CA at /etc/containers/certs.d//ca.crt; + # the chart mounts ${RELEASE_NAME}-dynamic-plugins-registry-auth at + # /opt/app-root/src/.config/containers. + disconnected::create_mirror_registry_ca_configmap "${NAME_SPACE}" || return 1 + disconnected::create_plugin_registry_auth_secret "${NAME_SPACE}" || return 1 log::section "Helm Deployment" diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index 40dcf9a975..fc93ff787c 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -128,6 +128,10 @@ handle_ocp_disconnected_operator() { namespace::configure "${NAME_SPACE}" disconnected::apply_plugin_mirror_configmap "${NAME_SPACE}" || return 1 + # Same CA/auth secrets as Helm so skopeo in install-dynamic-plugins can pull + # from the mirror (registries.conf alone is not enough — TLS verify fails). + disconnected::create_mirror_registry_ca_configmap "${NAME_SPACE}" || return 1 + disconnected::create_plugin_registry_auth_secret "${NAME_SPACE}" || return 1 log::section "Backstage CR Deployment" @@ -142,16 +146,43 @@ handle_ocp_disconnected_operator() { } local rendered_cr + local auth_secret="${RELEASE_NAME}-dynamic-plugins-registry-auth" rendered_cr=$(envsubst < "${DIR}/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml") + # Parity with helm-post-renderer.sh: registries.conf, policy.json, mirror CA. + # Also mount registry auth.json for authenticated mirror pulls. rendered_cr=$(echo "$rendered_cr" | yq eval \ - '.spec.application.extraFiles.configMaps = [ + --arg mirror "${MIRROR_REGISTRY_URL}" \ + --arg auth_secret "${auth_secret}" \ + ' + .spec.application.extraFiles.configMaps = [ { "name": "rhdh-plugin-mirror-conf", "key": "rhdh-registries.conf", "mountPath": "/etc/containers/registries.conf.d", "containers": ["install-dynamic-plugins"] + }, + { + "name": "rhdh-plugin-mirror-conf", + "key": "policy.json", + "mountPath": "/etc/containers", + "containers": ["install-dynamic-plugins"] + }, + { + "name": "mirror-registry-ca", + "key": "ca.crt", + "mountPath": ("/etc/containers/certs.d/" + $mirror), + "containers": ["install-dynamic-plugins"] + } + ] | + .spec.application.extraFiles.secrets = [ + { + "name": $auth_secret, + "key": "auth.json", + "mountPath": "/opt/app-root/src/.config/containers", + "containers": ["install-dynamic-plugins"] } - ]' -) + ] + ' -) local cr_temp="${DISCONNECTED_TMPDIR}/backstage-cr-disconnected.yaml" echo "$rendered_cr" > "${cr_temp}" diff --git a/.ci/pipelines/lib/disconnected.sh b/.ci/pipelines/lib/disconnected.sh index 5145daa874..573ec30170 100644 --- a/.ci/pipelines/lib/disconnected.sh +++ b/.ci/pipelines/lib/disconnected.sh @@ -320,6 +320,46 @@ disconnected::apply_plugin_mirror_configmap() { > "${ARTIFACT_DIR}/disconnected-plugin-mirror-configmap.yaml" 2> /dev/null || true } +# Create the per-namespace mirror CA ConfigMap used by install-dynamic-plugins +# (mounted at /etc/containers/certs.d//ca.crt so skopeo trusts TLS). +# Args: +# $1 - namespace +disconnected::create_mirror_registry_ca_configmap() { + local namespace=$1 + + if [[ ! -f "${MIRROR_REGISTRY_CA}" ]]; then + log::error "MIRROR_REGISTRY_CA file not found: ${MIRROR_REGISTRY_CA}" + return 1 + fi + + oc create configmap mirror-registry-ca \ + --from-file="ca.crt=${MIRROR_REGISTRY_CA}" \ + -n "${namespace}" \ + --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 ${namespace}" +} + +# Create the registry auth secret for the install-dynamic-plugins init container. +# Args: +# $1 - namespace +# $2 - secret name (default: ${RELEASE_NAME}-dynamic-plugins-registry-auth) +disconnected::create_plugin_registry_auth_secret() { + local namespace=$1 + local secret_name=${2:-${RELEASE_NAME}-dynamic-plugins-registry-auth} + + oc create secret generic "${secret_name}" \ + --from-file="auth.json=${MIRROR_REGISTRY_PULL_SECRET}" \ + -n "${namespace}" \ + --dry-run=client -o yaml | oc apply -f - || { + log::error "Failed to create registry auth secret — aborting" + return 1 + } + log::success "Secret ${secret_name} created in ${namespace}" +} + # Ensure the mirror registry CA is trusted cluster-wide via # image.config.openshift.io/cluster additionalTrustedCA so OLM v1 catalogd # can pull ClusterCatalog images (x509 unknown authority otherwise). @@ -462,6 +502,8 @@ export -f disconnected::with_unset_registry_auth_file export -f disconnected::wait_mcp_updated export -f disconnected::mirror_plugins export -f disconnected::apply_plugin_mirror_configmap +export -f disconnected::create_mirror_registry_ca_configmap +export -f disconnected::create_plugin_registry_auth_secret export -f disconnected::ensure_mirror_registry_ca export -f disconnected::ensure_olm_mirror_pull_secret export -f disconnected::dump_olm_v1_status From 901d536f0bdf6c7d7691482bf98de0064c113d5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Thu, 23 Jul 2026 10:54:47 +0200 Subject: [PATCH 34/45] fix(disconnected): avoid unsupported yq --arg in operator CR render CI mikefarah/yq rejected --arg when patching Backstage extraFiles. Interpolate mirror CA path and auth secret name via the shell instead. --- .../jobs/ocp-disconnected-operator.sh | 41 +++++++++---------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index fc93ff787c..78faa5bc1a 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -150,39 +150,38 @@ handle_ocp_disconnected_operator() { rendered_cr=$(envsubst < "${DIR}/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml") # Parity with helm-post-renderer.sh: registries.conf, policy.json, mirror CA. # Also mount registry auth.json for authenticated mirror pulls. - rendered_cr=$(echo "$rendered_cr" | yq eval \ - --arg mirror "${MIRROR_REGISTRY_URL}" \ - --arg auth_secret "${auth_secret}" \ - ' + # CI yq is mikefarah/yq (no --arg); interpolate paths via the shell. + # shellcheck disable=SC2016 + 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"] + \"name\": \"rhdh-plugin-mirror-conf\", + \"key\": \"rhdh-registries.conf\", + \"mountPath\": \"/etc/containers/registries.conf.d\", + \"containers\": [\"install-dynamic-plugins\"] }, { - "name": "rhdh-plugin-mirror-conf", - "key": "policy.json", - "mountPath": "/etc/containers", - "containers": ["install-dynamic-plugins"] + \"name\": \"rhdh-plugin-mirror-conf\", + \"key\": \"policy.json\", + \"mountPath\": \"/etc/containers\", + \"containers\": [\"install-dynamic-plugins\"] }, { - "name": "mirror-registry-ca", - "key": "ca.crt", - "mountPath": ("/etc/containers/certs.d/" + $mirror), - "containers": ["install-dynamic-plugins"] + \"name\": \"mirror-registry-ca\", + \"key\": \"ca.crt\", + \"mountPath\": \"/etc/containers/certs.d/${MIRROR_REGISTRY_URL}\", + \"containers\": [\"install-dynamic-plugins\"] } ] | .spec.application.extraFiles.secrets = [ { - "name": $auth_secret, - "key": "auth.json", - "mountPath": "/opt/app-root/src/.config/containers", - "containers": ["install-dynamic-plugins"] + \"name\": \"${auth_secret}\", + \"key\": \"auth.json\", + \"mountPath\": \"/opt/app-root/src/.config/containers\", + \"containers\": [\"install-dynamic-plugins\"] } ] - ' -) + " -) local cr_temp="${DISCONNECTED_TMPDIR}/backstage-cr-disconnected.yaml" echo "$rendered_cr" > "${cr_temp}" From 0becc3c255bf97f1e0c07a19c03f1b77b80e99be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Thu, 23 Jul 2026 10:55:58 +0200 Subject: [PATCH 35/45] fix(disconnected): always fetch prepare script from operator PR head Point prepare-restricted-environment.sh at refs/pull/3259/head so CI picks up the newest revision of the OLM v1 oc-mirror fix automatically. --- .ci/pipelines/jobs/ocp-disconnected-operator.sh | 11 +++++------ .ci/pipelines/lib/disconnected.sh | 11 +++++++++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index 78faa5bc1a..0670cda86e 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -33,15 +33,14 @@ handle_ocp_disconnected_operator() { # mirroring operator/operand images and installing the operator CatalogSource. log::section "Operator Mirroring and Installation" - # TEMPORARY: pin prepare-restricted-environment.sh to rhdh-operator PR #3259 + # TEMPORARY: always fetch prepare-restricted-environment.sh from the current + # head of rhdh-operator PR #3259 # (https://github.com/redhat-developer/rhdh-operator/pull/3259) so oc-mirror - # + OLM v1 uses native cc-*.yaml catalogs and skips re-applying a synthetic - # clusterCatalog.yaml that is missing on main. Revert to - # disconnected::fetch_script (branch default) once that PR merges. - local prepare_script_sha="0f0db074a3504bf61ff2cfbbc90a8089c4723822" + # + OLM v1 uses native cc-*.yaml catalogs. Revert to the branch default once + # that PR merges. local prepare_script_path="${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" disconnected::fetch_script "prepare-restricted-environment.sh" \ - "${prepare_script_path}" "${prepare_script_sha}" || { + "${prepare_script_path}" "pull/3259" || { log::error "Failed to fetch prepare-restricted-environment.sh — aborting" return 1 } diff --git a/.ci/pipelines/lib/disconnected.sh b/.ci/pipelines/lib/disconnected.sh index 573ec30170..59844353e0 100644 --- a/.ci/pipelines/lib/disconnected.sh +++ b/.ci/pipelines/lib/disconnected.sh @@ -243,18 +243,25 @@ disconnected::patch_idms() { # Args: # $1 - script_name: Name of the script (e.g., "mirror-plugins.sh") # $2 - output_path: Local path to save the script -# $3 - ref: (optional) Branch name or 40-char commit SHA -# (defaults to $RELEASE_BRANCH_NAME) +# $3 - ref: (optional) Branch name, 40-char commit SHA, or pull request +# ref ("pull/" / "refs/pull//head"). Defaults to +# $RELEASE_BRANCH_NAME. disconnected::fetch_script() { local script_name=$1 local output_path=$2 local ref="${3:-${RELEASE_BRANCH_NAME}}" local url local ref_label + local pull_number if [[ "${ref}" =~ ^[0-9a-f]{40}$ ]]; then url="https://raw.githubusercontent.com/redhat-developer/rhdh-operator/${ref}/.rhdh/scripts/${script_name}" ref_label="sha: ${ref}" + elif [[ "${ref}" =~ ^(refs/)?pull/([0-9]+)(/head)?$ ]]; then + # Always the current PR head (updates as the PR is pushed). + pull_number="${BASH_REMATCH[2]}" + url="https://raw.githubusercontent.com/redhat-developer/rhdh-operator/refs/pull/${pull_number}/head/.rhdh/scripts/${script_name}" + ref_label="pull/${pull_number} head" else url="https://raw.githubusercontent.com/redhat-developer/rhdh-operator/refs/heads/${ref}/.rhdh/scripts/${script_name}" ref_label="branch: ${ref}" From 295c6d44f7319ac5ad8b586916e042d7aef7f8ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Thu, 23 Jul 2026 13:40:17 +0200 Subject: [PATCH 36/45] fix(disconnected): avoid duplicate extraFiles ConfigMap volumes Operator reconcile failed because registries.conf and policy.json both referenced rhdh-plugin-mirror-conf, creating duplicate volume names. Mount policy.json from a separate ConfigMap instead. --- .ci/pipelines/jobs/ocp-disconnected-operator.sh | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index 0670cda86e..11de76f05a 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -132,6 +132,17 @@ handle_ocp_disconnected_operator() { disconnected::create_mirror_registry_ca_configmap "${NAME_SPACE}" || return 1 disconnected::create_plugin_registry_auth_secret "${NAME_SPACE}" || return 1 + # Operator mounts one volume per extraFiles ConfigMap name. policy.json cannot + # share rhdh-plugin-mirror-conf or reconcile fails with duplicate volume keys. + oc create configmap rhdh-plugin-mirror-policy \ + --from-literal='policy.json={"default":[{"type":"insecureAcceptAnything"}]}' \ + -n "${NAME_SPACE}" \ + --dry-run=client -o yaml | oc apply -f - || { + log::error "Failed to create rhdh-plugin-mirror-policy ConfigMap — aborting" + return 1 + } + log::success "ConfigMap rhdh-plugin-mirror-policy created in ${NAME_SPACE}" + log::section "Backstage CR Deployment" # Minimal guest-auth ConfigMap (full rhdh-start.yaml references ConfigMaps/Secrets @@ -160,7 +171,7 @@ handle_ocp_disconnected_operator() { \"containers\": [\"install-dynamic-plugins\"] }, { - \"name\": \"rhdh-plugin-mirror-conf\", + \"name\": \"rhdh-plugin-mirror-policy\", \"key\": \"policy.json\", \"mountPath\": \"/etc/containers\", \"containers\": [\"install-dynamic-plugins\"] From b0cb85546d207b088ff677cdfab7e86eba354bb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Thu, 23 Jul 2026 17:16:59 +0200 Subject: [PATCH 37/45] fix(disconnected): mount registry auth where the image allows it Operator subPath mounts to .config/containers failed (Not a directory). Mount auth.json under /tmp and set REGISTRY_AUTH_FILE for the init container. --- .ci/pipelines/jobs/ocp-disconnected-operator.sh | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index 11de76f05a..bf7a3911e5 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -162,7 +162,17 @@ handle_ocp_disconnected_operator() { # Also mount registry auth.json for authenticated mirror pulls. # CI yq is mikefarah/yq (no --arg); interpolate paths via the shell. # shellcheck disable=SC2016 + # Auth: operator secret mounts use subPath and require an existing parent dir. + # /opt/app-root/src/.config/containers is missing in the image, so mount to + # /tmp and point REGISTRY_AUTH_FILE at it (skopeo honors that). rendered_cr=$(echo "$rendered_cr" | yq eval " + .spec.application.extraEnvs.envs += [ + { + \"name\": \"REGISTRY_AUTH_FILE\", + \"value\": \"/tmp/auth.json\", + \"containers\": [\"install-dynamic-plugins\"] + } + ] | .spec.application.extraFiles.configMaps = [ { \"name\": \"rhdh-plugin-mirror-conf\", @@ -187,7 +197,7 @@ handle_ocp_disconnected_operator() { { \"name\": \"${auth_secret}\", \"key\": \"auth.json\", - \"mountPath\": \"/opt/app-root/src/.config/containers\", + \"mountPath\": \"/tmp\", \"containers\": [\"install-dynamic-plugins\"] } ] From 083b4b500af744c93abda15a9ee984fb2e080557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Fri, 24 Jul 2026 09:06:51 +0200 Subject: [PATCH 38/45] fix(disconnected): disable lightspeed and skip catalog index for operator smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator smoke was CrashLooping on plugin-catalog-index (not mirrored) and still starting lightspeed sidecars. Clear CATALOG_INDEX_IMAGE and set flavours: [] so guest→homepage smoke needs neither. --- .../rhdh-start-disconnected-smoke.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml b/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml index 11d6139de0..737e39656e 100644 --- a/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml +++ b/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml @@ -2,11 +2,20 @@ # Smoke only needs: guest login → homepage. Avoid ConfigMaps/Secrets that the # normal apply_yaml_files() flow creates (app-config-rhdh, dynamic-plugins-*, # rhdh-secrets, redis-secret). +# +# flavours: [] disables default flavours (lightspeed) so the deployment does not +# pull/start lightspeed-core / init-rag-data sidecars. +# +# CATALOG_INDEX_IMAGE="" clears the operator profile default (quay.io digest). +# Helm disconnected mirrors that index via oc-mirror; the operator path does not, +# and install-dynamic-plugins skips catalog extraction when the env is empty. +# Smoke does not need marketplace/default plugin configs from the index. kind: Backstage apiVersion: rhdh.redhat.com/v1alpha5 metadata: name: rhdh spec: + flavours: [] deployment: patch: spec: @@ -31,3 +40,7 @@ spec: value: "production" - name: NODE_TLS_REJECT_UNAUTHORIZED value: "0" + - name: CATALOG_INDEX_IMAGE + value: "" + containers: + - install-dynamic-plugins From d8300caecaadc8e774c6662dd95845a0131579d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Fri, 24 Jul 2026 11:22:10 +0200 Subject: [PATCH 39/45] fix(disconnected): use operator default hub image for smoke Stop patching the smoke CR with quay.io/rhdh-community/rhdh from IMAGE_* defaults. That ref is not in prepare IDMS; CSV registry.redhat.io hub is. --- .../jobs/ocp-disconnected-operator.sh | 7 +++++++ .../rhdh-start-disconnected-smoke.yaml | 18 +++++++----------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index bf7a3911e5..d9bf794ef3 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -145,6 +145,13 @@ handle_ocp_disconnected_operator() { log::section "Backstage CR Deployment" + # Hub image: do not force IMAGE_REGISTRY/IMAGE_REPO/TAG_NAME onto the CR. + # Defaults (quay.io/rhdh-community/rhdh:next) are not in prepare's IDMS + # (quay.io/rhdh, registry.redhat.io/rhdh). Operator CSV defaults already use + # registry.redhat.io/rhdh/rhdh-hub-rhel9, which prepare mirrored. Helm instead + # rewrites upstream.backstage.image.registry to MIRROR_REGISTRY_URL after + # oc-mirror; the operator path relies on IDMS rewrite of the CSV image. + # Minimal guest-auth ConfigMap (full rhdh-start.yaml references ConfigMaps/Secrets # created by apply_yaml_files(), which this disconnected handler skips). oc create configmap app-config-rhdh-disconnected-smoke \ diff --git a/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml b/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml index 737e39656e..562a14a9f2 100644 --- a/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml +++ b/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml @@ -6,6 +6,13 @@ # flavours: [] disables default flavours (lightspeed) so the deployment does not # pull/start lightspeed-core / init-rag-data sidecars. # +# Do NOT patch container images to $IMAGE_REGISTRY/$IMAGE_REPO:$TAG_NAME. +# Nightly defaults are quay.io/rhdh-community/rhdh:next, which prepare/IDMS do +# not mirror (only quay.io/rhdh and registry.redhat.io/rhdh). Leave images to +# the operator CSV defaults (registry.redhat.io/rhdh/rhdh-hub-rhel9@…) so +# ImageDigestMirrorSet from prepare-restricted-environment.sh can rewrite pulls +# to MIRROR_REGISTRY_URL — same hub already mirrored for the operand. +# # CATALOG_INDEX_IMAGE="" clears the operator profile default (quay.io digest). # Helm disconnected mirrors that index via oc-mirror; the operator path does not, # and install-dynamic-plugins skips catalog extraction when the env is empty. @@ -16,17 +23,6 @@ metadata: name: rhdh spec: flavours: [] - deployment: - patch: - spec: - template: - spec: - containers: - - name: backstage-backend - image: "$IMAGE_REGISTRY/$IMAGE_REPO:$TAG_NAME" - initContainers: - - name: install-dynamic-plugins - image: "$IMAGE_REGISTRY/$IMAGE_REPO:$TAG_NAME" application: appConfig: configMaps: From 71344480b1e3738305bb21bc125943ece208e844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Fri, 24 Jul 2026 13:17:22 +0200 Subject: [PATCH 40/45] fix(disconnected): point operator smoke at mirrored catalog index Empty CATALOG_INDEX_IMAGE skipped dynamic-plugins.default.yaml, so the home-page plugin never installed and guest login hit a / 404. Use the index already mirrored by mirror_plugins instead. --- .ci/pipelines/jobs/ocp-disconnected-operator.sh | 12 ++++++++++++ .../rhdh-start-disconnected-smoke.yaml | 13 +++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index d9bf794ef3..40da4a5aa4 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -123,6 +123,18 @@ handle_ocp_disconnected_operator() { log::section "Plugin Mirroring" disconnected::mirror_plugins || return 1 + # Homepage ("Welcome back!") comes from dynamic-plugins.default.yaml inside the + # catalog index. Clearing CATALOG_INDEX_IMAGE skips that file and / 404s after + # guest login. Rewrite to the index already pushed by mirror_plugins (same + # path under MIRROR_REGISTRY_URL). Do this AFTER mirroring so mirror_plugins + # still resolves the upstream/index override correctly. + if [[ -n "${CATALOG_INDEX_IMAGE:-}" ]]; then + export CATALOG_INDEX_IMAGE="${MIRROR_REGISTRY_URL}/${CATALOG_INDEX_IMAGE#*/}" + else + export CATALOG_INDEX_IMAGE="${MIRROR_REGISTRY_URL}/rhdh/plugin-catalog-index:${RELEASE_VERSION}" + fi + log::info "Using mirrored CATALOG_INDEX_IMAGE=${CATALOG_INDEX_IMAGE}" + log::section "Namespace and Secrets" namespace::configure "${NAME_SPACE}" diff --git a/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml b/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml index 562a14a9f2..bba1dd7325 100644 --- a/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml +++ b/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml @@ -13,10 +13,11 @@ # ImageDigestMirrorSet from prepare-restricted-environment.sh can rewrite pulls # to MIRROR_REGISTRY_URL — same hub already mirrored for the operand. # -# CATALOG_INDEX_IMAGE="" clears the operator profile default (quay.io digest). -# Helm disconnected mirrors that index via oc-mirror; the operator path does not, -# and install-dynamic-plugins skips catalog extraction when the env is empty. -# Smoke does not need marketplace/default plugin configs from the index. +# CATALOG_INDEX_IMAGE is injected by deploy_rhdh_operator() after +# ocp-disconnected-operator.sh rewrites it to MIRROR_REGISTRY_URL/… (the index +# already mirrored by disconnected::mirror_plugins). That image provides +# dynamic-plugins.default.yaml — without it, the home-page plugin is missing +# and guest login lands on a / 404 ("We couldn't find that page"). kind: Backstage apiVersion: rhdh.redhat.com/v1alpha5 metadata: @@ -36,7 +37,3 @@ spec: value: "production" - name: NODE_TLS_REJECT_UNAUTHORIZED value: "0" - - name: CATALOG_INDEX_IMAGE - value: "" - containers: - - install-dynamic-plugins From 41f186c60de65bbdf8e1f547e82346a7276f5c1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Sun, 26 Jul 2026 16:07:37 +0200 Subject: [PATCH 41/45] fix(disconnected): mirror CI hub for operator catalog defaults plugin-catalog-index:next enables a local homepage dist path that GA rhdh-hub-rhel9 lacks. Match Helm by skopeo-copying the CI hub to the mirror and pointing the smoke CR at it (flavours remain empty). --- .../jobs/ocp-disconnected-operator.sh | 44 ++++++++-- .ci/pipelines/lib/disconnected.sh | 85 ++++++++++++++++++- .../rhdh-start-disconnected-smoke.yaml | 30 +++++-- 3 files changed, 139 insertions(+), 20 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index 40da4a5aa4..f911329271 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -135,6 +135,12 @@ handle_ocp_disconnected_operator() { fi log::info "Using mirrored CATALOG_INDEX_IMAGE=${CATALOG_INDEX_IMAGE}" + # Hub must match the catalog index tag (nightly :next). CSV GA hub lacks the + # local homepage path referenced by plugin-catalog-index:next. Mirror the CI + # hub like Helm's oc-mirror additionalImages, then point the CR at the mirror. + log::section "Hub Image Mirroring" + disconnected::mirror_hub_image || return 1 + log::section "Namespace and Secrets" namespace::configure "${NAME_SPACE}" @@ -144,6 +150,19 @@ handle_ocp_disconnected_operator() { disconnected::create_mirror_registry_ca_configmap "${NAME_SPACE}" || return 1 disconnected::create_plugin_registry_auth_secret "${NAME_SPACE}" || return 1 + # Kubelet pull of ${MIRROR_REGISTRY_URL}/${IMAGE_REPO}:${TAG_NAME} (CR patch). + # Cluster pull-secret is already merged; namespace secret + imagePullSecrets + # matches the K8s operator CR pattern and avoids relying on that alone. + oc create secret generic mirror-registry-pull \ + --from-file=.dockerconfigjson="${MIRROR_REGISTRY_PULL_SECRET}" \ + --type=kubernetes.io/dockerconfigjson \ + -n "${NAME_SPACE}" \ + --dry-run=client -o yaml | oc apply -f - || { + log::error "Failed to create mirror-registry-pull secret — aborting" + return 1 + } + log::success "Secret mirror-registry-pull created in ${NAME_SPACE}" + # Operator mounts one volume per extraFiles ConfigMap name. policy.json cannot # share rhdh-plugin-mirror-conf or reconcile fails with duplicate volume keys. oc create configmap rhdh-plugin-mirror-policy \ @@ -157,13 +176,6 @@ handle_ocp_disconnected_operator() { log::section "Backstage CR Deployment" - # Hub image: do not force IMAGE_REGISTRY/IMAGE_REPO/TAG_NAME onto the CR. - # Defaults (quay.io/rhdh-community/rhdh:next) are not in prepare's IDMS - # (quay.io/rhdh, registry.redhat.io/rhdh). Operator CSV defaults already use - # registry.redhat.io/rhdh/rhdh-hub-rhel9, which prepare mirrored. Helm instead - # rewrites upstream.backstage.image.registry to MIRROR_REGISTRY_URL after - # oc-mirror; the operator path relies on IDMS rewrite of the CSV image. - # Minimal guest-auth ConfigMap (full rhdh-start.yaml references ConfigMaps/Secrets # created by apply_yaml_files(), which this disconnected handler skips). oc create configmap app-config-rhdh-disconnected-smoke \ @@ -174,6 +186,24 @@ handle_ocp_disconnected_operator() { return 1 } + # Homepage via OCI dynamic-home-page (already mirrored). Wired into the CR as + # spec.application.dynamicPluginsConfigMapName: dynamic-plugins. + local dynamic_plugins_yaml="${DISCONNECTED_TMPDIR}/dynamic-plugins-disconnected-smoke.yaml" + envsubst < "${DIR}/resources/config_map/dynamic-plugins-disconnected-smoke.yaml" \ + > "${dynamic_plugins_yaml}" || { + log::error "Failed to render dynamic-plugins ConfigMap content — aborting" + return 1 + } + oc create configmap dynamic-plugins \ + --from-file="dynamic-plugins.yaml=${dynamic_plugins_yaml}" \ + --namespace="${NAME_SPACE}" \ + --dry-run=client -o yaml | oc apply -f - || { + log::error "Failed to create dynamic-plugins ConfigMap — aborting" + return 1 + } + log::success "ConfigMap dynamic-plugins created in ${NAME_SPACE}" + cp "${dynamic_plugins_yaml}" "${ARTIFACT_DIR}/disconnected-dynamic-plugins.yaml" 2> /dev/null || true + local rendered_cr local auth_secret="${RELEASE_NAME}-dynamic-plugins-registry-auth" rendered_cr=$(envsubst < "${DIR}/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml") diff --git a/.ci/pipelines/lib/disconnected.sh b/.ci/pipelines/lib/disconnected.sh index 59844353e0..d38d4db45c 100644 --- a/.ci/pipelines/lib/disconnected.sh +++ b/.ci/pipelines/lib/disconnected.sh @@ -286,6 +286,84 @@ disconnected::wait_mcp_updated() { log::success "All MachineConfigPools are Updated" } +# Mirror the CI hub image (IMAGE_REGISTRY/IMAGE_REPO:TAG_NAME) into the +# disconnected mirror registry. Operator prepare/IDMS covers +# registry.redhat.io/rhdh/rhdh-hub-rhel9, not quay.io/rhdh-community/rhdh:next. +# Helm gets this via oc-mirror additionalImages; operator needs an explicit copy +# so install-dynamic-plugins can use local plugins from the matching hub. +disconnected::mirror_hub_image() { + common::require_vars IMAGE_REGISTRY IMAGE_REPO TAG_NAME MIRROR_REGISTRY_URL || return 1 + + local src="docker://${IMAGE_REGISTRY}/${IMAGE_REPO}:${TAG_NAME}" + local dest="docker://${MIRROR_REGISTRY_URL}/${IMAGE_REPO}:${TAG_NAME}" + + log::info "Mirroring hub image ${IMAGE_REGISTRY}/${IMAGE_REPO}:${TAG_NAME} → ${MIRROR_REGISTRY_URL}/${IMAGE_REPO}:${TAG_NAME}" + if ! skopeo copy --all --dest-tls-verify=false "${src}" "${dest}"; then + log::error "Failed to mirror hub image ${src} → ${dest}" + return 1 + fi + log::success "Hub image mirrored to ${MIRROR_REGISTRY_URL}/${IMAGE_REPO}:${TAG_NAME}" +} + +# Plugin catalog index OCI URL used by mirror_plugins / plugin_registry_ref. +# Prefers CATALOG_INDEX_IMAGE when set; otherwise GA plugin-catalog-index tag. +disconnected::plugin_index_oci() { + if [[ -n "${CATALOG_INDEX_IMAGE:-}" ]]; then + echo "oci://${CATALOG_INDEX_IMAGE}" + else + echo "oci://registry.access.redhat.com/rhdh/plugin-catalog-index:${RELEASE_VERSION}" + fi +} + +# Resolve a plugin's registryReference from the plugin catalog index (index.json). +# Args: +# $1 - plugin key (e.g. red-hat-developer-hub-backstage-plugin-dynamic-home-page) +# Prints the registry reference (registry.access.redhat.com/rhdh/…@sha256:…). +disconnected::plugin_registry_ref() { + local plugin_key=$1 + local plugin_index + local extract_dir + local layer + local ref + + if [[ -z "${plugin_key}" ]]; then + log::error "disconnected::plugin_registry_ref requires a plugin key" + return 1 + fi + + plugin_index=$(disconnected::plugin_index_oci) + extract_dir="${DISCONNECTED_TMPDIR}/plugin-index-$(echo "${plugin_key}" | tr '/:' '__')" + rm -rf "${extract_dir}" + mkdir -p "${extract_dir}/img" "${extract_dir}/root" + + # Same amd64/linux override CI runners use; local arm64 hosts need it too. + if ! skopeo copy --override-os linux --override-arch amd64 \ + "docker://${plugin_index#oci://}" "dir:${extract_dir}/img"; then + log::error "Failed to copy plugin catalog index ${plugin_index}" + return 1 + fi + + for layer in "${extract_dir}/img"/*; do + if [[ -f "${layer}" && ! "${layer}" =~ (manifest\.json|version)$ ]]; then + tar -xf "${layer}" -C "${extract_dir}/root" 2> /dev/null || true + fi + done + + if [[ ! -f "${extract_dir}/root/index.json" ]]; then + log::error "No index.json in plugin catalog index ${plugin_index}" + return 1 + fi + + ref=$(jq -r --arg key "${plugin_key}" '.[$key].registryReference // empty' \ + "${extract_dir}/root/index.json") + if [[ -z "${ref}" || "${ref}" == "null" ]]; then + log::error "Plugin '${plugin_key}' has no registryReference in ${plugin_index}" + return 1 + fi + + echo "${ref}" +} + # Fetch and run mirror-plugins.sh against the disconnected mirror registry. # Uses CATALOG_INDEX_IMAGE when set; otherwise the GA plugin-catalog-index tag. disconnected::mirror_plugins() { @@ -296,10 +374,8 @@ disconnected::mirror_plugins() { 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 + local plugin_index + plugin_index=$(disconnected::plugin_index_oci) bash "${mirror_script}" \ --plugin-index "${plugin_index}" \ @@ -507,6 +583,7 @@ export -f disconnected::setup_auth export -f disconnected::fetch_script export -f disconnected::with_unset_registry_auth_file export -f disconnected::wait_mcp_updated +export -f disconnected::mirror_hub_image export -f disconnected::mirror_plugins export -f disconnected::apply_plugin_mirror_configmap export -f disconnected::create_mirror_registry_ca_configmap diff --git a/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml b/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml index bba1dd7325..b2eee7dce9 100644 --- a/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml +++ b/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml @@ -6,24 +6,36 @@ # flavours: [] disables default flavours (lightspeed) so the deployment does not # pull/start lightspeed-core / init-rag-data sidecars. # -# Do NOT patch container images to $IMAGE_REGISTRY/$IMAGE_REPO:$TAG_NAME. -# Nightly defaults are quay.io/rhdh-community/rhdh:next, which prepare/IDMS do -# not mirror (only quay.io/rhdh and registry.redhat.io/rhdh). Leave images to -# the operator CSV defaults (registry.redhat.io/rhdh/rhdh-hub-rhel9@…) so -# ImageDigestMirrorSet from prepare-restricted-environment.sh can rewrite pulls -# to MIRROR_REGISTRY_URL — same hub already mirrored for the operand. +# Hub image must match CATALOG_INDEX_IMAGE (both :next for nightly). The :next +# catalog's dynamic-plugins.default.yaml references local +# ./dynamic-plugins/dist/red-hat-developer-hub-backstage-plugin-homepage, which +# exists in quay.io/rhdh-community/rhdh:next but not in the GA CSV hub +# (registry.redhat.io/rhdh/rhdh-hub-rhel9). Helm mirrors the CI hub via +# oc-mirror and sets upstream.backstage.image.registry=MIRROR; operator does +# the same with a skopeo copy + deployment patch to MIRROR_REGISTRY_URL. # # CATALOG_INDEX_IMAGE is injected by deploy_rhdh_operator() after # ocp-disconnected-operator.sh rewrites it to MIRROR_REGISTRY_URL/… (the index -# already mirrored by disconnected::mirror_plugins). That image provides -# dynamic-plugins.default.yaml — without it, the home-page plugin is missing -# and guest login lands on a / 404 ("We couldn't find that page"). +# already mirrored by disconnected::mirror_plugins). kind: Backstage apiVersion: rhdh.redhat.com/v1alpha5 metadata: name: rhdh spec: flavours: [] + deployment: + patch: + spec: + template: + spec: + imagePullSecrets: + - name: mirror-registry-pull + containers: + - name: backstage-backend + image: "$MIRROR_REGISTRY_URL/$IMAGE_REPO:$TAG_NAME" + initContainers: + - name: install-dynamic-plugins + image: "$MIRROR_REGISTRY_URL/$IMAGE_REPO:$TAG_NAME" application: appConfig: configMaps: From 72f93768fe5fcde3e352ef4abdcf6fe0dc8a4728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Sun, 26 Jul 2026 16:08:06 +0200 Subject: [PATCH 42/45] fix(disconnected): drop broken dynamic-plugins ConfigMap path Remove leftover ConfigMap wiring from a conflicting edit; homepage comes from catalog defaults on the mirrored CI hub image. --- .../jobs/ocp-disconnected-operator.sh | 18 ----- .ci/pipelines/lib/disconnected.sh | 65 ++----------------- 2 files changed, 4 insertions(+), 79 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index f911329271..c5d47a2a3d 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -186,24 +186,6 @@ handle_ocp_disconnected_operator() { return 1 } - # Homepage via OCI dynamic-home-page (already mirrored). Wired into the CR as - # spec.application.dynamicPluginsConfigMapName: dynamic-plugins. - local dynamic_plugins_yaml="${DISCONNECTED_TMPDIR}/dynamic-plugins-disconnected-smoke.yaml" - envsubst < "${DIR}/resources/config_map/dynamic-plugins-disconnected-smoke.yaml" \ - > "${dynamic_plugins_yaml}" || { - log::error "Failed to render dynamic-plugins ConfigMap content — aborting" - return 1 - } - oc create configmap dynamic-plugins \ - --from-file="dynamic-plugins.yaml=${dynamic_plugins_yaml}" \ - --namespace="${NAME_SPACE}" \ - --dry-run=client -o yaml | oc apply -f - || { - log::error "Failed to create dynamic-plugins ConfigMap — aborting" - return 1 - } - log::success "ConfigMap dynamic-plugins created in ${NAME_SPACE}" - cp "${dynamic_plugins_yaml}" "${ARTIFACT_DIR}/disconnected-dynamic-plugins.yaml" 2> /dev/null || true - local rendered_cr local auth_secret="${RELEASE_NAME}-dynamic-plugins-registry-auth" rendered_cr=$(envsubst < "${DIR}/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml") diff --git a/.ci/pipelines/lib/disconnected.sh b/.ci/pipelines/lib/disconnected.sh index d38d4db45c..bc22724621 100644 --- a/.ci/pipelines/lib/disconnected.sh +++ b/.ci/pipelines/lib/disconnected.sh @@ -305,65 +305,6 @@ disconnected::mirror_hub_image() { log::success "Hub image mirrored to ${MIRROR_REGISTRY_URL}/${IMAGE_REPO}:${TAG_NAME}" } -# Plugin catalog index OCI URL used by mirror_plugins / plugin_registry_ref. -# Prefers CATALOG_INDEX_IMAGE when set; otherwise GA plugin-catalog-index tag. -disconnected::plugin_index_oci() { - if [[ -n "${CATALOG_INDEX_IMAGE:-}" ]]; then - echo "oci://${CATALOG_INDEX_IMAGE}" - else - echo "oci://registry.access.redhat.com/rhdh/plugin-catalog-index:${RELEASE_VERSION}" - fi -} - -# Resolve a plugin's registryReference from the plugin catalog index (index.json). -# Args: -# $1 - plugin key (e.g. red-hat-developer-hub-backstage-plugin-dynamic-home-page) -# Prints the registry reference (registry.access.redhat.com/rhdh/…@sha256:…). -disconnected::plugin_registry_ref() { - local plugin_key=$1 - local plugin_index - local extract_dir - local layer - local ref - - if [[ -z "${plugin_key}" ]]; then - log::error "disconnected::plugin_registry_ref requires a plugin key" - return 1 - fi - - plugin_index=$(disconnected::plugin_index_oci) - extract_dir="${DISCONNECTED_TMPDIR}/plugin-index-$(echo "${plugin_key}" | tr '/:' '__')" - rm -rf "${extract_dir}" - mkdir -p "${extract_dir}/img" "${extract_dir}/root" - - # Same amd64/linux override CI runners use; local arm64 hosts need it too. - if ! skopeo copy --override-os linux --override-arch amd64 \ - "docker://${plugin_index#oci://}" "dir:${extract_dir}/img"; then - log::error "Failed to copy plugin catalog index ${plugin_index}" - return 1 - fi - - for layer in "${extract_dir}/img"/*; do - if [[ -f "${layer}" && ! "${layer}" =~ (manifest\.json|version)$ ]]; then - tar -xf "${layer}" -C "${extract_dir}/root" 2> /dev/null || true - fi - done - - if [[ ! -f "${extract_dir}/root/index.json" ]]; then - log::error "No index.json in plugin catalog index ${plugin_index}" - return 1 - fi - - ref=$(jq -r --arg key "${plugin_key}" '.[$key].registryReference // empty' \ - "${extract_dir}/root/index.json") - if [[ -z "${ref}" || "${ref}" == "null" ]]; then - log::error "Plugin '${plugin_key}' has no registryReference in ${plugin_index}" - return 1 - fi - - echo "${ref}" -} - # Fetch and run mirror-plugins.sh against the disconnected mirror registry. # Uses CATALOG_INDEX_IMAGE when set; otherwise the GA plugin-catalog-index tag. disconnected::mirror_plugins() { @@ -374,8 +315,10 @@ disconnected::mirror_plugins() { return 1 } - local plugin_index - plugin_index=$(disconnected::plugin_index_oci) + 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 "${mirror_script}" \ --plugin-index "${plugin_index}" \ From 8f57e6226e649f0472c6b30823cbde8bd2185392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Sun, 26 Jul 2026 19:57:38 +0200 Subject: [PATCH 43/45] fix(disconnected): force mirrored hub on install-dynamic-plugins Operator getInitContainer overwrites CR init image patches with RELATED_IMAGE_backstage (CSV GA). Point RELATED_IMAGE at the mirrored CI hub and post-patch the Deployment so homepage plugins resolve. --- .../jobs/ocp-disconnected-operator.sh | 14 +++ .ci/pipelines/lib/disconnected.sh | 102 ++++++++++++++++++ .../rhdh-start-disconnected-smoke.yaml | 6 ++ 3 files changed, 122 insertions(+) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index c5d47a2a3d..04480e4f4b 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -140,6 +140,9 @@ handle_ocp_disconnected_operator() { # hub like Helm's oc-mirror additionalImages, then point the CR at the mirror. log::section "Hub Image Mirroring" disconnected::mirror_hub_image || return 1 + # RELATED_IMAGE_backstage overrides install-dynamic-plugins after CR patches + # (operator getInitContainer clobber). Must run before the Backstage CR. + disconnected::set_operator_related_hub_image "rhdh-operator" || return 1 log::section "Namespace and Secrets" @@ -239,9 +242,20 @@ handle_ocp_disconnected_operator() { cp "${cr_temp}" "${ARTIFACT_DIR}/disconnected-backstage-cr.yaml" 2> /dev/null || true + # Re-assert RELATED_IMAGE in case OLM v1 reconciled the operator Deployment + # back to the CSV GA value while we prepared the namespace. + disconnected::set_operator_related_hub_image "rhdh-operator" || return 1 + deploy_rhdh_operator "${NAME_SPACE}" "${cr_temp}" log::success "Backstage CR deployed in ${NAME_SPACE}" + # CR deployment.patch sets both images, but the operator re-applies + # RELATED_IMAGE_backstage onto install-dynamic-plugins after the merge + # (backend patch sticks; init does not). RELATED_IMAGE was set above; also + # force-patch the live Deployment before smoke wait in case the first + # reconcile raced the operator rollout. + disconnected::patch_backstage_hub_images "${NAME_SPACE}" || return 1 + log::section "Smoke Test" local url="https://backstage-${RELEASE_NAME}-${NAME_SPACE}.${K8S_CLUSTER_ROUTER_BASE}" diff --git a/.ci/pipelines/lib/disconnected.sh b/.ci/pipelines/lib/disconnected.sh index bc22724621..3b6efcdd59 100644 --- a/.ci/pipelines/lib/disconnected.sh +++ b/.ci/pipelines/lib/disconnected.sh @@ -305,6 +305,106 @@ disconnected::mirror_hub_image() { log::success "Hub image mirrored to ${MIRROR_REGISTRY_URL}/${IMAGE_REPO}:${TAG_NAME}" } +# Point the operator's RELATED_IMAGE_backstage at the mirrored CI hub. +# +# Why: rhdh-operator applies spec.deployment.patch via strategic merge (by +# container name), so the CR can set backstage-backend.image. But +# DynamicPlugins.getInitContainer() later force-sets install-dynamic-plugins +# to RELATED_IMAGE_backstage (CSV GA digest), clobbering the CR init image. +# Setting RELATED_IMAGE to the mirrored hub makes that override correct. +# +# Args: +# $1 - optional operator namespace (default: rhdh-operator) +disconnected::set_operator_related_hub_image() { + common::require_vars IMAGE_REPO TAG_NAME MIRROR_REGISTRY_URL || return 1 + + local operator_ns="${1:-rhdh-operator}" + local hub_image="${MIRROR_REGISTRY_URL}/${IMAGE_REPO}:${TAG_NAME}" + local deploy + + deploy=$(oc get deploy -n "${operator_ns}" \ + -l app.kubernetes.io/component=backstage-controller \ + -o jsonpath='{.items[0].metadata.name}' 2> /dev/null || true) + if [[ -z "${deploy}" ]]; then + deploy=$(oc get deploy -n "${operator_ns}" -o name 2> /dev/null \ + | grep -E 'rhdh-operator|backstage-controller' | head -1 | sed 's|^deployment.apps/||;s|^deployments.apps/||;s|^deployment/||') + fi + if [[ -z "${deploy}" ]]; then + log::error "Could not find rhdh-operator Deployment in ${operator_ns}" + oc get deploy -n "${operator_ns}" || true + return 1 + fi + + log::info "Setting RELATED_IMAGE_backstage=${hub_image} on deploy/${deploy} (${operator_ns})" + oc set env "deployment/${deploy}" -n "${operator_ns}" \ + "RELATED_IMAGE_backstage=${hub_image}" || { + log::error "Failed to set RELATED_IMAGE_backstage on deploy/${deploy}" + return 1 + } + if ! oc rollout status "deployment/${deploy}" -n "${operator_ns}" --timeout=300s; then + log::error "Operator rollout after RELATED_IMAGE_backstage update timed out" + return 1 + fi + log::success "Operator RELATED_IMAGE_backstage → ${hub_image}" +} + +# Force both hub containers on the Backstage Deployment to the mirrored CI image. +# +# CR-native init image patches do not stick (see set_operator_related_hub_image). +# RELATED_IMAGE covers subsequent reconciles; this post-apply covers the first +# Deployment create if the operator pod had not yet picked up the new env. +# +# Args: +# $1 - Backstage namespace +disconnected::patch_backstage_hub_images() { + common::require_vars IMAGE_REPO TAG_NAME MIRROR_REGISTRY_URL || return 1 + + local namespace="$1" + local hub_image="${MIRROR_REGISTRY_URL}/${IMAGE_REPO}:${TAG_NAME}" + local deploy + + deploy=$(oc get deployment -n "${namespace}" -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' \ + | grep -E '^backstage-' | grep -vE 'psql|postgres' | head -1) + if [[ -z "${deploy}" ]]; then + log::error "No backstage Deployment found in ${namespace} to patch hub images" + oc get deployment -n "${namespace}" || true + return 1 + fi + + log::info "Patching deploy/${deploy} hub images → ${hub_image}" + # Strategic merge matches containers/initContainers by name (same as operator). + # oc set image only updates spec.containers, not initContainers. + oc patch "deployment/${deploy}" -n "${namespace}" --type=strategic -p "{ + \"spec\": { + \"template\": { + \"spec\": { + \"containers\": [ + {\"name\": \"backstage-backend\", \"image\": \"${hub_image}\"} + ], + \"initContainers\": [ + {\"name\": \"install-dynamic-plugins\", \"image\": \"${hub_image}\"} + ] + } + } + } + }" || { + log::error "Failed to patch hub images on deploy/${deploy}" + return 1 + } + + local backend_img init_img + backend_img=$(oc get "deployment/${deploy}" -n "${namespace}" \ + -o jsonpath='{range .spec.template.spec.containers[?(@.name=="backstage-backend")]}{.image}{end}') + init_img=$(oc get "deployment/${deploy}" -n "${namespace}" \ + -o jsonpath='{range .spec.template.spec.initContainers[?(@.name=="install-dynamic-plugins")]}{.image}{end}') + log::info "deploy/${deploy} images: backstage-backend=${backend_img} install-dynamic-plugins=${init_img}" + if [[ "${backend_img}" != "${hub_image}" || "${init_img}" != "${hub_image}" ]]; then + log::error "Hub image patch did not stick (want ${hub_image})" + return 1 + fi + log::success "Backstage Deployment hub images set to ${hub_image}" +} + # Fetch and run mirror-plugins.sh against the disconnected mirror registry. # Uses CATALOG_INDEX_IMAGE when set; otherwise the GA plugin-catalog-index tag. disconnected::mirror_plugins() { @@ -527,6 +627,8 @@ export -f disconnected::fetch_script export -f disconnected::with_unset_registry_auth_file export -f disconnected::wait_mcp_updated export -f disconnected::mirror_hub_image +export -f disconnected::set_operator_related_hub_image +export -f disconnected::patch_backstage_hub_images export -f disconnected::mirror_plugins export -f disconnected::apply_plugin_mirror_configmap export -f disconnected::create_mirror_registry_ca_configmap diff --git a/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml b/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml index b2eee7dce9..5f54ea7e5f 100644 --- a/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml +++ b/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml @@ -14,6 +14,12 @@ # oc-mirror and sets upstream.backstage.image.registry=MIRROR; operator does # the same with a skopeo copy + deployment patch to MIRROR_REGISTRY_URL. # +# Init-container caveat: strategic merge applies this patch by name, and +# backstage-backend keeps the mirrored image. install-dynamic-plugins does not +# stick — DynamicPlugins.getInitContainer() rewrites it to RELATED_IMAGE_backstage +# (CSV GA). ocp-disconnected-operator.sh therefore also sets RELATED_IMAGE_backstage +# on the operator Deployment and post-patches the live Deployment. +# # CATALOG_INDEX_IMAGE is injected by deploy_rhdh_operator() after # ocp-disconnected-operator.sh rewrites it to MIRROR_REGISTRY_URL/… (the index # already mirrored by disconnected::mirror_plugins). From 09442eb90092dcc34dd4549a3823513c072c2949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Mon, 27 Jul 2026 11:40:40 +0200 Subject: [PATCH 44/45] chore(ci): fetch prepare-restricted from RELEASE_BRANCH_NAME rhdh-operator#3259 is merged on main, so drop the temporary pull/3259 pin. --- .ci/pipelines/jobs/ocp-disconnected-operator.sh | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index 04480e4f4b..678e8b786a 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -33,14 +33,12 @@ handle_ocp_disconnected_operator() { # mirroring operator/operand images and installing the operator CatalogSource. log::section "Operator Mirroring and Installation" - # TEMPORARY: always fetch prepare-restricted-environment.sh from the current - # head of rhdh-operator PR #3259 - # (https://github.com/redhat-developer/rhdh-operator/pull/3259) so oc-mirror - # + OLM v1 uses native cc-*.yaml catalogs. Revert to the branch default once - # that PR merges. + # Fetch prepare-restricted-environment.sh from RELEASE_BRANCH_NAME (default + # main). rhdh-operator#3259 (native oc-mirror cc-* catalogs for OLM v1) is + # merged on main. local prepare_script_path="${DISCONNECTED_TMPDIR}/prepare-restricted-environment.sh" disconnected::fetch_script "prepare-restricted-environment.sh" \ - "${prepare_script_path}" "pull/3259" || { + "${prepare_script_path}" || { log::error "Failed to fetch prepare-restricted-environment.sh — aborting" return 1 } @@ -51,8 +49,7 @@ handle_ocp_disconnected_operator() { # CATALOG_INDEX_IMAGE is the plugin catalog index — do not pass it as # --index-image (OLM operator catalog). Keep it for mirror-plugins.sh below. # - # OLM version: leave default (auto). On OCP 4.21+ this selects OLM v1; - # the temporary prepare-script pin above fixes the oc-mirror + v1 path. + # OLM version: leave default (auto). On OCP 4.21+ this selects OLM v1. local filter_versions="${RELEASE_VERSION}" if [[ "${filter_versions}" == "next" || "${filter_versions}" == "*" ]]; then filter_versions="*" From 75b7b722bad9b8a732a548e7eb0c6e4a17282b62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbyn=C4=9Bk=20Dr=C3=A1pela?= Date: Mon, 27 Jul 2026 16:01:38 +0200 Subject: [PATCH 45/45] fix(disconnected): digest-pin hub via CR patch and RELATED_IMAGE Pin HUB_IMAGE to the mirrored digest, declare it on the Backstage CR deployment.patch, and set RELATED_IMAGE_backstage to the same ref so install-dynamic-plugins is not left on the CSV GA hub. --- .../jobs/ocp-disconnected-operator.sh | 19 ++- .ci/pipelines/lib/disconnected.sh | 128 ++++++++++-------- .../rhdh-start-disconnected-smoke.yaml | 25 ++-- 3 files changed, 95 insertions(+), 77 deletions(-) diff --git a/.ci/pipelines/jobs/ocp-disconnected-operator.sh b/.ci/pipelines/jobs/ocp-disconnected-operator.sh index 678e8b786a..bca3aa52b4 100644 --- a/.ci/pipelines/jobs/ocp-disconnected-operator.sh +++ b/.ci/pipelines/jobs/ocp-disconnected-operator.sh @@ -134,9 +134,11 @@ handle_ocp_disconnected_operator() { # Hub must match the catalog index tag (nightly :next). CSV GA hub lacks the # local homepage path referenced by plugin-catalog-index:next. Mirror the CI - # hub like Helm's oc-mirror additionalImages, then point the CR at the mirror. + # hub like Helm's oc-mirror additionalImages, digest-pin as HUB_IMAGE, then + # set RELATED_IMAGE_backstage (authoritative for install-dynamic-plugins). log::section "Hub Image Mirroring" disconnected::mirror_hub_image || return 1 + disconnected::resolve_hub_image || return 1 # RELATED_IMAGE_backstage overrides install-dynamic-plugins after CR patches # (operator getInitContainer clobber). Must run before the Backstage CR. disconnected::set_operator_related_hub_image "rhdh-operator" || return 1 @@ -150,9 +152,8 @@ handle_ocp_disconnected_operator() { disconnected::create_mirror_registry_ca_configmap "${NAME_SPACE}" || return 1 disconnected::create_plugin_registry_auth_secret "${NAME_SPACE}" || return 1 - # Kubelet pull of ${MIRROR_REGISTRY_URL}/${IMAGE_REPO}:${TAG_NAME} (CR patch). - # Cluster pull-secret is already merged; namespace secret + imagePullSecrets - # matches the K8s operator CR pattern and avoids relying on that alone. + # Kubelet pull of HUB_IMAGE (CR patch). Cluster pull-secret is already merged; + # namespace secret + imagePullSecrets matches the K8s operator CR pattern. oc create secret generic mirror-registry-pull \ --from-file=.dockerconfigjson="${MIRROR_REGISTRY_PULL_SECRET}" \ --type=kubernetes.io/dockerconfigjson \ @@ -188,6 +189,7 @@ handle_ocp_disconnected_operator() { local rendered_cr local auth_secret="${RELEASE_NAME}-dynamic-plugins-registry-auth" + # envsubst substitutes HUB_IMAGE into spec.deployment.patch container images. rendered_cr=$(envsubst < "${DIR}/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml") # Parity with helm-post-renderer.sh: registries.conf, policy.json, mirror CA. # Also mount registry auth.json for authenticated mirror pulls. @@ -246,12 +248,9 @@ handle_ocp_disconnected_operator() { deploy_rhdh_operator "${NAME_SPACE}" "${cr_temp}" log::success "Backstage CR deployed in ${NAME_SPACE}" - # CR deployment.patch sets both images, but the operator re-applies - # RELATED_IMAGE_backstage onto install-dynamic-plugins after the merge - # (backend patch sticks; init does not). RELATED_IMAGE was set above; also - # force-patch the live Deployment before smoke wait in case the first - # reconcile raced the operator rollout. - disconnected::patch_backstage_hub_images "${NAME_SPACE}" || return 1 + # Init image comes from RELATED_IMAGE_backstage (not the CR patch alone). + # Read-only verify both containers match HUB_IMAGE before smoke. + disconnected::verify_backstage_hub_images "${NAME_SPACE}" || return 1 log::section "Smoke Test" diff --git a/.ci/pipelines/lib/disconnected.sh b/.ci/pipelines/lib/disconnected.sh index 3b6efcdd59..60e2706b07 100644 --- a/.ci/pipelines/lib/disconnected.sh +++ b/.ci/pipelines/lib/disconnected.sh @@ -305,21 +305,29 @@ disconnected::mirror_hub_image() { log::success "Hub image mirrored to ${MIRROR_REGISTRY_URL}/${IMAGE_REPO}:${TAG_NAME}" } -# Point the operator's RELATED_IMAGE_backstage at the mirrored CI hub. -# -# Why: rhdh-operator applies spec.deployment.patch via strategic merge (by -# container name), so the CR can set backstage-backend.image. But -# DynamicPlugins.getInitContainer() later force-sets install-dynamic-plugins -# to RELATED_IMAGE_backstage (CSV GA digest), clobbering the CR init image. -# Setting RELATED_IMAGE to the mirrored hub makes that override correct. -# -# Args: -# $1 - optional operator namespace (default: rhdh-operator) -disconnected::set_operator_related_hub_image() { +# Resolve the mirrored hub to a digest-pinned ref and export HUB_IMAGE. +# Call after disconnected::mirror_hub_image. HUB_IMAGE is used by the smoke CR +# (envsubst) and by RELATED_IMAGE_backstage so init + backend stay exact. +disconnected::resolve_hub_image() { common::require_vars IMAGE_REPO TAG_NAME MIRROR_REGISTRY_URL || return 1 + local ref="${MIRROR_REGISTRY_URL}/${IMAGE_REPO}:${TAG_NAME}" + local digest + + log::info "Resolving digest for mirrored hub ${ref}" + digest=$(skopeo inspect --tls-verify=false "docker://${ref}" --format '{{.Digest}}' 2> /dev/null || true) + if [[ -z "${digest}" || "${digest}" != sha256:* ]]; then + log::error "Failed to resolve digest for docker://${ref}" + return 1 + fi + + export HUB_IMAGE="${MIRROR_REGISTRY_URL}/${IMAGE_REPO}@${digest}" + log::success "HUB_IMAGE=${HUB_IMAGE}" +} + +# Locate the rhdh-operator controller Deployment name in a namespace. +disconnected::_operator_deploy_name() { local operator_ns="${1:-rhdh-operator}" - local hub_image="${MIRROR_REGISTRY_URL}/${IMAGE_REPO}:${TAG_NAME}" local deploy deploy=$(oc get deploy -n "${operator_ns}" \ @@ -334,6 +342,28 @@ disconnected::set_operator_related_hub_image() { oc get deploy -n "${operator_ns}" || true return 1 fi + printf '%s\n' "${deploy}" +} + +# Point the operator's RELATED_IMAGE_backstage at HUB_IMAGE (digest-pinned). +# +# Why: rhdh-operator applies spec.deployment.patch via strategic merge (by +# container name), so the CR can set backstage-backend.image. But +# DynamicPlugins.getInitContainer() later force-sets install-dynamic-plugins +# to RELATED_IMAGE_backstage, clobbering the CR init image. RELATED_IMAGE must +# equal the exact hub ref declared on the CR. +# +# Args: +# $1 - optional operator namespace (default: rhdh-operator) +disconnected::set_operator_related_hub_image() { + common::require_vars HUB_IMAGE || return 1 + + local operator_ns="${1:-rhdh-operator}" + local hub_image="${HUB_IMAGE}" + local deploy + local actual + + deploy=$(disconnected::_operator_deploy_name "${operator_ns}") || return 1 log::info "Setting RELATED_IMAGE_backstage=${hub_image} on deploy/${deploy} (${operator_ns})" oc set env "deployment/${deploy}" -n "${operator_ns}" \ @@ -345,64 +375,55 @@ disconnected::set_operator_related_hub_image() { log::error "Operator rollout after RELATED_IMAGE_backstage update timed out" return 1 fi + + actual=$(oc get "deployment/${deploy}" -n "${operator_ns}" \ + -o jsonpath='{range .spec.template.spec.containers[*].env[?(@.name=="RELATED_IMAGE_backstage")]}{.value}{end}' 2> /dev/null || true) + if [[ "${actual}" != "${hub_image}" ]]; then + log::error "RELATED_IMAGE_backstage on deploy/${deploy} is '${actual}', want '${hub_image}' (OLM may have reverted)" + return 1 + fi log::success "Operator RELATED_IMAGE_backstage → ${hub_image}" } -# Force both hub containers on the Backstage Deployment to the mirrored CI image. -# -# CR-native init image patches do not stick (see set_operator_related_hub_image). -# RELATED_IMAGE covers subsequent reconciles; this post-apply covers the first -# Deployment create if the operator pod had not yet picked up the new env. +# Read-only check that both Backstage hub containers use HUB_IMAGE. +# Init must match RELATED_IMAGE_backstage (operator getInitContainer); the CR +# patch alone cannot keep install-dynamic-plugins on a different image. # # Args: # $1 - Backstage namespace -disconnected::patch_backstage_hub_images() { - common::require_vars IMAGE_REPO TAG_NAME MIRROR_REGISTRY_URL || return 1 +disconnected::verify_backstage_hub_images() { + common::require_vars HUB_IMAGE || return 1 local namespace="$1" - local hub_image="${MIRROR_REGISTRY_URL}/${IMAGE_REPO}:${TAG_NAME}" + local hub_image="${HUB_IMAGE}" local deploy + local backend_img init_img + local attempt deploy=$(oc get deployment -n "${namespace}" -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' \ | grep -E '^backstage-' | grep -vE 'psql|postgres' | head -1) if [[ -z "${deploy}" ]]; then - log::error "No backstage Deployment found in ${namespace} to patch hub images" + log::error "No backstage Deployment found in ${namespace} to verify hub images" oc get deployment -n "${namespace}" || true return 1 fi - log::info "Patching deploy/${deploy} hub images → ${hub_image}" - # Strategic merge matches containers/initContainers by name (same as operator). - # oc set image only updates spec.containers, not initContainers. - oc patch "deployment/${deploy}" -n "${namespace}" --type=strategic -p "{ - \"spec\": { - \"template\": { - \"spec\": { - \"containers\": [ - {\"name\": \"backstage-backend\", \"image\": \"${hub_image}\"} - ], - \"initContainers\": [ - {\"name\": \"install-dynamic-plugins\", \"image\": \"${hub_image}\"} - ] - } - } - } - }" || { - log::error "Failed to patch hub images on deploy/${deploy}" - return 1 - } + # Allow a short reconcile window after CR apply + RELATED_IMAGE set. + for attempt in 1 2 3 4 5 6; do + backend_img=$(oc get "deployment/${deploy}" -n "${namespace}" \ + -o jsonpath='{range .spec.template.spec.containers[?(@.name=="backstage-backend")]}{.image}{end}') + init_img=$(oc get "deployment/${deploy}" -n "${namespace}" \ + -o jsonpath='{range .spec.template.spec.initContainers[?(@.name=="install-dynamic-plugins")]}{.image}{end}') + log::info "deploy/${deploy} images (attempt ${attempt}): backstage-backend=${backend_img} install-dynamic-plugins=${init_img}" + if [[ "${backend_img}" == "${hub_image}" && "${init_img}" == "${hub_image}" ]]; then + log::success "Backstage Deployment hub images match HUB_IMAGE=${hub_image}" + return 0 + fi + sleep 5 + done - local backend_img init_img - backend_img=$(oc get "deployment/${deploy}" -n "${namespace}" \ - -o jsonpath='{range .spec.template.spec.containers[?(@.name=="backstage-backend")]}{.image}{end}') - init_img=$(oc get "deployment/${deploy}" -n "${namespace}" \ - -o jsonpath='{range .spec.template.spec.initContainers[?(@.name=="install-dynamic-plugins")]}{.image}{end}') - log::info "deploy/${deploy} images: backstage-backend=${backend_img} install-dynamic-plugins=${init_img}" - if [[ "${backend_img}" != "${hub_image}" || "${init_img}" != "${hub_image}" ]]; then - log::error "Hub image patch did not stick (want ${hub_image})" - return 1 - fi - log::success "Backstage Deployment hub images set to ${hub_image}" + log::error "RELATED_IMAGE_backstage not effective on install-dynamic-plugins (want ${hub_image}; backend=${backend_img}; init=${init_img})" + return 1 } # Fetch and run mirror-plugins.sh against the disconnected mirror registry. @@ -627,8 +648,9 @@ export -f disconnected::fetch_script export -f disconnected::with_unset_registry_auth_file export -f disconnected::wait_mcp_updated export -f disconnected::mirror_hub_image +export -f disconnected::resolve_hub_image export -f disconnected::set_operator_related_hub_image -export -f disconnected::patch_backstage_hub_images +export -f disconnected::verify_backstage_hub_images export -f disconnected::mirror_plugins export -f disconnected::apply_plugin_mirror_configmap export -f disconnected::create_mirror_registry_ca_configmap diff --git a/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml b/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml index 5f54ea7e5f..3c5f70b424 100644 --- a/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml +++ b/.ci/pipelines/resources/rhdh-operator/rhdh-start-disconnected-smoke.yaml @@ -6,19 +6,16 @@ # flavours: [] disables default flavours (lightspeed) so the deployment does not # pull/start lightspeed-core / init-rag-data sidecars. # -# Hub image must match CATALOG_INDEX_IMAGE (both :next for nightly). The :next -# catalog's dynamic-plugins.default.yaml references local -# ./dynamic-plugins/dist/red-hat-developer-hub-backstage-plugin-homepage, which -# exists in quay.io/rhdh-community/rhdh:next but not in the GA CSV hub -# (registry.redhat.io/rhdh/rhdh-hub-rhel9). Helm mirrors the CI hub via -# oc-mirror and sets upstream.backstage.image.registry=MIRROR; operator does -# the same with a skopeo copy + deployment patch to MIRROR_REGISTRY_URL. +# Exact hub image: $HUB_IMAGE is a digest-pinned mirror ref +# (${MIRROR_REGISTRY_URL}/${IMAGE_REPO}@sha256:…). It must match +# CATALOG_INDEX_IMAGE (nightly :next catalog expects local homepage dist from +# the community hub, not GA rhdh-hub-rhel9). # -# Init-container caveat: strategic merge applies this patch by name, and -# backstage-backend keeps the mirrored image. install-dynamic-plugins does not -# stick — DynamicPlugins.getInitContainer() rewrites it to RELATED_IMAGE_backstage -# (CSV GA). ocp-disconnected-operator.sh therefore also sets RELATED_IMAGE_backstage -# on the operator Deployment and post-patches the live Deployment. +# CRD surface: spec.deployment.patch merges containers/initContainers by name +# (K8s strategic merge / operator merge2). That alone is not enough for +# install-dynamic-plugins — DynamicPlugins.getInitContainer() re-applies +# RELATED_IMAGE_backstage after the patch. ocp-disconnected-operator.sh therefore +# sets RELATED_IMAGE_backstage=$HUB_IMAGE on the operator Deployment as well. # # CATALOG_INDEX_IMAGE is injected by deploy_rhdh_operator() after # ocp-disconnected-operator.sh rewrites it to MIRROR_REGISTRY_URL/… (the index @@ -38,10 +35,10 @@ spec: - name: mirror-registry-pull containers: - name: backstage-backend - image: "$MIRROR_REGISTRY_URL/$IMAGE_REPO:$TAG_NAME" + image: "$HUB_IMAGE" initContainers: - name: install-dynamic-plugins - image: "$MIRROR_REGISTRY_URL/$IMAGE_REPO:$TAG_NAME" + image: "$HUB_IMAGE" application: appConfig: configMaps: