From b285c2778f62611f9a47ce3245400d26dd68b13d Mon Sep 17 00:00:00 2001 From: Dmitrii Andreev Date: Thu, 3 Sep 2026 16:05:22 -0500 Subject: [PATCH] HYPERFLEET-1412 - feat: Support disconnected installs --- Makefile | 42 +++- README.md | 5 + bundle.konflux.Dockerfile | 6 +- ...rfleet-operator.clusterserviceversion.yaml | 10 +- config/deployable-images.yaml | 6 + config/manager/kustomization.yaml | 10 +- docs/bundle.md | 10 +- docs/disconnected-install.md | 111 +++++++++ docs/examples/imageset-config-standalone.yaml | 10 + hack/bundle/add_operator_related_image.sh | 57 +++++ {bundle-hack => hack/bundle}/update_bundle.sh | 16 +- hack/oc-mirror.Dockerfile | 35 +++ hack/test-disconnected-mirror.sh | 151 ++++++++++++ hack/verify-related-images/main.go | 233 ++++++++++++++++++ hack/verify-related-images/main_test.go | 124 ++++++++++ internal/component/api/api.go | 6 +- 16 files changed, 811 insertions(+), 21 deletions(-) create mode 100644 config/deployable-images.yaml create mode 100644 docs/disconnected-install.md create mode 100644 docs/examples/imageset-config-standalone.yaml create mode 100755 hack/bundle/add_operator_related_image.sh rename {bundle-hack => hack/bundle}/update_bundle.sh (66%) create mode 100644 hack/oc-mirror.Dockerfile create mode 100755 hack/test-disconnected-mirror.sh create mode 100644 hack/verify-related-images/main.go create mode 100644 hack/verify-related-images/main_test.go diff --git a/Makefile b/Makefile index 87fc497..e4b67c7 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ VERSION ?= 0.0.1 # Set the Operator SDK version to use. By default, what is installed on the system is used. # This is useful for CI or a project to utilize a specific version of the operator-sdk toolkit. OPERATOR_SDK_VERSION ?= v1.42.3 +YQ_VERSION ?= v4.44.1 # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) ifeq (,$(shell go env GOBIN)) @@ -120,8 +121,12 @@ cleanup-test-e2e: ## Tear down the Kind cluster used for e2e tests ##@ Lint +.PHONY: verify-related-images +verify-related-images: ## Verify immutable deployable images match CSV relatedImages. + go run ./hack/verify-related-images + .PHONY: lint -lint: ## Run golangci-lint linter +lint: verify-related-images ## Run image verification and golangci-lint. $(GOLANGCI_LINT) run .PHONY: lint-fix @@ -134,6 +139,18 @@ lint-config: ## Verify golangci-lint linter configuration ##@ Build +OC_MIRROR_IMAGE ?= hyperfleet-oc-mirror:local + +.PHONY: build-oc-mirror-image +build-oc-mirror-image: ## Build the containerized oc-mirror runner. + $(CONTAINER_TOOL) build -f hack/oc-mirror.Dockerfile -t $(OC_MIRROR_IMAGE) . + +.PHONY: test-disconnected-mirror +test-disconnected-mirror: ## Exercise the disk-to-mirror archive transfer. + CONTAINER_TOOL=$(CONTAINER_TOOL) OC_MIRROR_IMAGE=$(OC_MIRROR_IMAGE) \ + BUNDLE_IMAGE="$(BUNDLE_IMAGE)" OPERATOR_IMAGE="$(OPERATOR_IMAGE)" API_IMAGE="$(API_IMAGE)" \ + ./hack/test-disconnected-mirror.sh + .PHONY: build build: manifests generate fmt vet ## Build manager binary. go build -o bin/manager cmd/main.go @@ -350,16 +367,18 @@ FROM_INDEX_OPT := --from-index $(CATALOG_BASE_IMG) endif .PHONY: bundle -bundle: manifests operator-sdk ## Generate bundle manifests and metadata, then validate generated files. +bundle: manifests operator-sdk yq ## Generate bundle manifests and metadata, then validate generated files. $(OPERATOR_SDK) generate kustomize manifests -q $(KUSTOMIZE) build config/manifests | $(OPERATOR_SDK) generate bundle $(BUNDLE_GEN_FLAGS) + HYPERFLEET_OPERATOR_IMAGE_PULLSPEC="$$($(YQ) eval '.spec.install.spec.deployments[].spec.template.spec.containers[] | select(.name == "manager") | .image' bundle/manifests/hyperfleet-operator.clusterserviceversion.yaml)" CSV_FILE=bundle/manifests/hyperfleet-operator.clusterserviceversion.yaml ./hack/bundle/add_operator_related_image.sh $(OPERATOR_SDK) bundle validate ./bundle .PHONY: bundle-override-img -bundle-override-img: manifests operator-sdk ## Generate bundle with IMG override, then restore kustomization.yaml +bundle-override-img: manifests operator-sdk yq ## Generate bundle with IMG override, then restore kustomization.yaml $(OPERATOR_SDK) generate kustomize manifests -q cd config/manager && $(KUSTOMIZE) edit set image controller=$(IMG) $(KUSTOMIZE) build config/manifests | $(OPERATOR_SDK) generate bundle $(BUNDLE_GEN_FLAGS) + HYPERFLEET_OPERATOR_IMAGE_PULLSPEC="$$($(YQ) eval '.spec.install.spec.deployments[].spec.template.spec.containers[] | select(.name == "manager") | .image' bundle/manifests/hyperfleet-operator.clusterserviceversion.yaml)" CSV_FILE=bundle/manifests/hyperfleet-operator.clusterserviceversion.yaml ./hack/bundle/add_operator_related_image.sh --allow-tag $(OPERATOR_SDK) bundle validate ./bundle @echo "Bundle generated with IMG=$(IMG)" @echo "Note: config/manager/kustomization.yaml has been modified. Commit or reset as needed." @@ -396,6 +415,23 @@ $(LOCALBIN): KUBECTL ?= kubectl KIND ?= kind +.PHONY: yq +YQ ?= $(LOCALBIN)/yq +yq: ## Download yq locally if necessary. +ifeq (,$(wildcard $(YQ))) +ifeq (, $(shell which yq 2>/dev/null)) + @{ \ + set -e ;\ + mkdir -p $(dir $(YQ)) ;\ + OS=$(shell go env GOOS) && ARCH=$(shell go env GOARCH) && \ + curl -sSLo $(YQ) https://github.com/mikefarah/yq/releases/download/$(YQ_VERSION)/yq_$${OS}_$${ARCH} ;\ + chmod +x $(YQ) ;\ + } +else +YQ = $(shell which yq) +endif +endif + .PHONY: operator-sdk OPERATOR_SDK ?= $(LOCALBIN)/operator-sdk operator-sdk: ## Download operator-sdk locally if necessary. diff --git a/README.md b/README.md index da5a05e..d3498fe 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,11 @@ A Kubernetes operator for HyperFleet cluster lifecycle management. hyperfleet-operator packages and delivers HyperFleet as a standard Kubernetes operator, installed and managed through OLM. It exposes a single cluster-scoped custom resource, `HyperFleetConfig`, as the entire partner-facing surface: install, configure, and observe HyperFleet through that one CR and its status conditions, with everything else the operator manages kept internal. +## Installation guides + +- [Bundle development and installation](docs/bundle.md) +- [Disconnected OpenShift installation with oc-mirror v2](docs/disconnected-install.md) + ## Getting Started ### Prerequisites diff --git a/bundle.konflux.Dockerfile b/bundle.konflux.Dockerfile index eddc143..26d3e9f 100644 --- a/bundle.konflux.Dockerfile +++ b/bundle.konflux.Dockerfile @@ -1,5 +1,5 @@ # Konflux bundle image build. Unlike the auto-generated bundle.Dockerfile (used -# for local dev with operator-sdk), this runs bundle-hack/update_bundle.sh to +# for local dev with operator-sdk), this runs hack/bundle/update_bundle.sh to # patch digest-pinned image references into the CSV at build time. FROM registry.access.redhat.com/ubi9/ubi-minimal:latest AS builder-runner RUN microdnf install -y tar gzip && \ @@ -12,10 +12,10 @@ FROM builder-runner AS builder ARG HYPERFLEET_OPERATOR_IMAGE_PULLSPEC="quay.io/redhat-user-workloads/hyperfleet-tenant/hyperfleet/hyperfleet-operator@sha256:31e365d312b6f3d483913d4029eba565abd64ab38a6d117658324afe225f708d" ENV HYPERFLEET_OPERATOR_IMAGE_PULLSPEC=${HYPERFLEET_OPERATOR_IMAGE_PULLSPEC} -ARG HYPERFLEET_API_IMAGE_PULLSPEC="quay.io/redhat-user-workloads/hyperfleet-tenant/hyperfleet/hyperfleet-api@sha256:8533d0d875480f31f5112e454659a095a5d2e993c139a9045a06be6b67b829ca" +ARG HYPERFLEET_API_IMAGE_PULLSPEC="quay.io/redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-api@sha256:8533d0d875480f31f5112e454659a095a5d2e993c139a9045a06be6b67b829ca" ENV HYPERFLEET_API_IMAGE_PULLSPEC=${HYPERFLEET_API_IMAGE_PULLSPEC} -COPY bundle-hack . +COPY hack/bundle . COPY bundle/manifests /manifests/ RUN ./update_bundle.sh diff --git a/bundle/manifests/hyperfleet-operator.clusterserviceversion.yaml b/bundle/manifests/hyperfleet-operator.clusterserviceversion.yaml index 2654e73..89039e4 100644 --- a/bundle/manifests/hyperfleet-operator.clusterserviceversion.yaml +++ b/bundle/manifests/hyperfleet-operator.clusterserviceversion.yaml @@ -33,7 +33,7 @@ metadata: } ] capabilities: Basic Install - createdAt: "2026-09-01T18:11:57Z" + createdAt: "2026-09-03T22:24:44Z" operators.operatorframework.io/builder: operator-sdk-v1.42.3 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 name: hyperfleet-operator.v0.0.1 @@ -169,8 +169,8 @@ spec: fieldRef: fieldPath: metadata.namespace - name: RELATED_IMAGE_HYPERFLEET_API - value: quay.io/redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-api:latest - image: quay.io/redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-operator:latest + value: quay.io/redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-api@sha256:8533d0d875480f31f5112e454659a095a5d2e993c139a9045a06be6b67b829ca + image: quay.io/redhat-user-workloads/hyperfleet-tenant/hyperfleet/hyperfleet-operator@sha256:31e365d312b6f3d483913d4029eba565abd64ab38a6d117658324afe225f708d livenessProbe: httpGet: path: /healthz @@ -266,6 +266,8 @@ spec: name: Red Hat url: https://github.com/openshift-hyperfleet relatedImages: - - image: quay.io/redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-api:latest + - image: quay.io/redhat-user-workloads/hyperfleet-tenant/hyperfleet/hyperfleet-operator@sha256:31e365d312b6f3d483913d4029eba565abd64ab38a6d117658324afe225f708d + name: hyperfleet-operator + - image: quay.io/redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-api@sha256:8533d0d875480f31f5112e454659a095a5d2e993c139a9045a06be6b67b829ca name: hyperfleet-api version: 0.0.1 diff --git a/config/deployable-images.yaml b/config/deployable-images.yaml new file mode 100644 index 0000000..664a023 --- /dev/null +++ b/config/deployable-images.yaml @@ -0,0 +1,6 @@ +# Source of truth for the images deployable by the bundle CSV. +images: +- name: hyperfleet-operator + image: quay.io/redhat-user-workloads/hyperfleet-tenant/hyperfleet/hyperfleet-operator@sha256:31e365d312b6f3d483913d4029eba565abd64ab38a6d117658324afe225f708d +- name: hyperfleet-api + image: quay.io/redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-api@sha256:8533d0d875480f31f5112e454659a095a5d2e993c139a9045a06be6b67b829ca diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml index 93a768e..45d3c71 100644 --- a/config/manager/kustomization.yaml +++ b/config/manager/kustomization.yaml @@ -4,8 +4,8 @@ kind: Kustomization resources: - manager.yaml -# Base image configuration for local development -# For production/Konflux builds, digest-pinned images are set via bundle.konflux.Dockerfile ARG overrides +# Immutable production defaults. Local development may override these values +# with the targets below; mutable development images must not be published. # # Local development override: # make bundle-override-img IMG=quay.io//hyperfleet-operator:dev- @@ -15,8 +15,8 @@ resources: # git checkout config/manager/kustomization.yaml images: - name: controller - newName: quay.io/redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-operator - newTag: latest + newName: quay.io/redhat-user-workloads/hyperfleet-tenant/hyperfleet/hyperfleet-operator + digest: sha256:31e365d312b6f3d483913d4029eba565abd64ab38a6d117658324afe225f708d # RELATED_IMAGE_HYPERFLEET_API environment variable # Sets the image used for the API operand. @@ -34,7 +34,7 @@ patches: path: /spec/template/spec/containers/0/env/- value: name: RELATED_IMAGE_HYPERFLEET_API - value: quay.io/redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-api:latest + value: quay.io/redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-api@sha256:8533d0d875480f31f5112e454659a095a5d2e993c139a9045a06be6b67b829ca target: kind: Deployment name: controller-manager diff --git a/docs/bundle.md b/docs/bundle.md index 99587ba..d609b7b 100644 --- a/docs/bundle.md +++ b/docs/bundle.md @@ -1,3 +1,9 @@ +# Bundle installation and development + +For an OpenShift 4.18+ air-gapped installation using oc-mirror v2, follow the +[disconnected installation guide](disconnected-install.md). This document +covers bundle production and connected development workflows. + ## Pre-merge checks 1. Updates to bundle.Dockerfile are also reflected in bundle.konflux.Dockerfile 2. bundle/ is correctly updated before merging @@ -9,8 +15,8 @@ Once Konflux is in place, the CI pipeline will automatically handle bundling bui 1. Konflux builds the operator image and publishes it to quay.io/redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-operator 2. The operator-bundle .tekton pipeline will be triggered by any update to the bundle.konflux.Dockerfile -2. `bundle.konflux.Dockerfile` runs `update_bundle.sh` with the new operator image reference -3. `update_bundle.sh` uses yq to update the CSV to ensure the operator deployment has proper values - image, relatedImages, annotations, etc. +2. `bundle.konflux.Dockerfile` runs `hack/bundle/update_bundle.sh` with the new operator image reference +3. `hack/bundle/update_bundle.sh` uses yq to update the CSV to ensure the operator deployment has proper values - image, relatedImages, annotations, etc. 4. Publishes the operator-bundle to quay.io/redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-operator-bundle (HYPERFLEET-1411: TODO add more information once the konflux pipelines are in place) diff --git a/docs/disconnected-install.md b/docs/disconnected-install.md new file mode 100644 index 0000000..605a30f --- /dev/null +++ b/docs/disconnected-install.md @@ -0,0 +1,111 @@ +# Disconnected mirroring with oc-mirror v2 + +This is the minimal disk-to-registry procedure for the HyperFleet Operator. +Use a released bundle, operator image, and API image identified by immutable +`@sha256:<64 lowercase hex characters>` pullspecs. + +## Prerequisites + +* `oc-mirror` v2 on the connected and disconnected hosts +* read access to the source registries +* write access to the disconnected registry +* transfer media with enough space for the archive +* an auth file and registry trust configured on both hosts + +Do not put credentials in the configuration file: + +```bash +export REGISTRY_AUTH_FILE=$HOME/.config/containers/auth.json +``` + +## 1. Create one ImageSetConfiguration + +Set up the mirror workspace and copy [`docs/examples/imageset-config-standalone.yaml`](examples/imageset-config-standalone.yaml): + +```bash +export MIRROR_ROOT="$HOME/hyperfleet-mirror" +rm -rf "$MIRROR_ROOT" +mkdir -p "$MIRROR_ROOT" +cp docs/examples/imageset-config-standalone.yaml "$MIRROR_ROOT/imageset-config.yaml" +``` + +Edit `$MIRROR_ROOT/imageset-config.yaml` to replace its three example pullspecs +with the released bundle, operator, and API image digests. The file uses +`additionalImages` intentionally: it transfers exactly the three listed +artifacts and does not discover a catalog or CSV. + +Run the image check before mirroring: + +```bash +make verify-related-images +``` + +It verifies this equality: + +```text +deployable-images.yaml == CSV manager image + RELATED_IMAGE_* values == CSV spec.relatedImages +``` + +## 2. Mirror to disk on the connected host + +```bash +oc-mirror --v2 \ + --config "$MIRROR_ROOT/imageset-config.yaml" \ + file://"$MIRROR_ROOT/archive" +``` + +Keep the complete `archive` directory. Transfer it and the configuration to the +disconnected mirroring host using approved media: + +```bash +tar -C "$MIRROR_ROOT" \ + -czf /media/transfer/hyperfleet-mirror.tgz \ + archive imageset-config.yaml + +mkdir -p /var/tmp/hyperfleet-mirror +tar -C /var/tmp/hyperfleet-mirror \ + -xzf /media/transfer/hyperfleet-mirror.tgz +``` + +## 3. Mirror from disk into the disconnected registry + +```bash +export DESTINATION='mirror.example.com:8443/hyperfleet' + +oc-mirror --v2 \ + --config /var/tmp/hyperfleet-mirror/imageset-config.yaml \ + --from file:///var/tmp/hyperfleet-mirror/archive \ + docker://"$DESTINATION" +``` + +Save the command output. Apply any generated mirror resources to the cluster +before installing the bundle, for example: + +```bash +CLUSTER_RESOURCES="$(find /var/tmp/hyperfleet-mirror \ + -type d -path '*/working-dir/cluster-resources' | head -1)" +oc apply -f "$CLUSTER_RESOURCES" +``` + +Use the mirrored bundle with the normal installation procedure. The +`additionalImages` configuration does not create a catalog. Do not manually +rewrite image digests. + +## Acceptance evidence + +Exercise the connected disk export and disconnected import procedure once on an +isolated OpenShift cluster. Record the command output in the PR Test Plan, +including the cluster and OpenShift version, destination registry, mirrored +bundle digest, successful CSV/operator/API readiness, and runtime image IDs +ending in the expected digests. Do not include credentials or Secret values. + +For a repeatable local transfer check, use the containerized oc-mirror runner: + +```bash +BUNDLE_IMAGE='quay.io/.../hyperfleet-operator-bundle@sha256:' \ +make test-disconnected-mirror +``` + +The runner is built from [`hack/oc-mirror.Dockerfile`](../hack/oc-mirror.Dockerfile) +and deliberately keeps oc-mirror in a container. This check exercises archive +transfer only; it is not a cluster installation framework. diff --git a/docs/examples/imageset-config-standalone.yaml b/docs/examples/imageset-config-standalone.yaml new file mode 100644 index 0000000..ce6f17a --- /dev/null +++ b/docs/examples/imageset-config-standalone.yaml @@ -0,0 +1,10 @@ +# Standalone registry+v1 fallback. Replace every example with an authenticated +# released pullspec. additionalImages copies exactly these artifacts; it does +# not discover bundle relatedImages and does not create an OLM catalog. +apiVersion: mirror.openshift.io/v2alpha1 +kind: ImageSetConfiguration +mirror: + additionalImages: + - name: registry.example.com/hyperfleet/hyperfleet-operator-bundle@sha256:0000000000000000000000000000000000000000000000000000000000000000 + - name: registry.example.com/hyperfleet/hyperfleet-operator@sha256:0000000000000000000000000000000000000000000000000000000000000000 + - name: registry.example.com/hyperfleet/hyperfleet-api@sha256:0000000000000000000000000000000000000000000000000000000000000000 diff --git a/hack/bundle/add_operator_related_image.sh b/hack/bundle/add_operator_related_image.sh new file mode 100755 index 0000000..288399f --- /dev/null +++ b/hack/bundle/add_operator_related_image.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +CSV_FILE="${CSV_FILE:-bundle/manifests/hyperfleet-operator.clusterserviceversion.yaml}" +image="${HYPERFLEET_OPERATOR_IMAGE_PULLSPEC:-}" +allow_tag=false + +case "${1:-}" in + "") ;; + --allow-tag) + allow_tag=true + ;; + *) + echo "usage: $0 [--allow-tag]" >&2 + exit 1 + ;; +esac + +if [[ "$image" =~ ^[^[:space:]]+@sha256:[0-9a-f]{64}$ ]]; then + : +elif [[ "$allow_tag" == true && "$image" =~ ^[^[:space:]@]+:[^[:space:]@/]+$ ]]; then + : +else + if [[ "$allow_tag" == true ]]; then + echo "error: HYPERFLEET_OPERATOR_IMAGE_PULLSPEC must be a sha256 digest pullspec or tagged pullspec" >&2 + else + echo "error: HYPERFLEET_OPERATOR_IMAGE_PULLSPEC must be a sha256 digest pullspec" >&2 + fi + exit 1 +fi +[[ -f "$CSV_FILE" ]] || { echo "error: CSV not found: $CSV_FILE" >&2; exit 1; } + +# operator-sdk derives operand relatedImages from RELATED_IMAGE_* variables but +# does not include the manager image itself. Insert that one generated entry +# without reserializing the whole generated CSV. +if grep -q '^[[:space:]]*name: hyperfleet-operator$' "$CSV_FILE"; then + echo "error: CSV already contains a hyperfleet-operator relatedImages entry" >&2 + exit 1 +fi +if [[ "$(grep -c '^ relatedImages:$' "$CSV_FILE")" -ne 1 ]]; then + echo "error: expected exactly one spec.relatedImages block in $CSV_FILE" >&2 + exit 1 +fi + +tmp="$(mktemp "${CSV_FILE}.XXXXXX")" +trap 'rm -f "$tmp"' EXIT +awk -v image="$image" ' + /^ relatedImages:$/ { + print + print " - image: " image + print " name: hyperfleet-operator" + next + } + { print } +' "$CSV_FILE" >"$tmp" +mv "$tmp" "$CSV_FILE" +trap - EXIT diff --git a/bundle-hack/update_bundle.sh b/hack/bundle/update_bundle.sh similarity index 66% rename from bundle-hack/update_bundle.sh rename to hack/bundle/update_bundle.sh index 5f0ceed..f89aa60 100755 --- a/bundle-hack/update_bundle.sh +++ b/hack/bundle/update_bundle.sh @@ -2,9 +2,23 @@ set -euo pipefail CSV_FILE="${CSV_FILE:-/manifests/hyperfleet-operator.clusterserviceversion.yaml}" +YQ="${YQ:-yq}" + +require_digest_pullspec() { + local variable_name="$1" + local pullspec="${!variable_name:-}" + if [[ ! "${pullspec}" =~ ^[^[:space:]]+@sha256:[0-9a-f]{64}$ ]]; then + echo "error: ${variable_name} must be a non-empty sha256 digest pullspec, got '${pullspec}'" >&2 + exit 1 + fi +} + +require_digest_pullspec HYPERFLEET_OPERATOR_IMAGE_PULLSPEC +require_digest_pullspec HYPERFLEET_API_IMAGE_PULLSPEC +[[ -f "${CSV_FILE}" ]] || { echo "error: CSV not found: ${CSV_FILE}" >&2; exit 1; } # Update image references in the CSV file using yq -yq eval ' +"${YQ}" eval ' # Update operator deployment image (.spec.install.spec.deployments[].spec.template.spec.containers[] | select(.name == "manager") | .image) = strenv(HYPERFLEET_OPERATOR_IMAGE_PULLSPEC) | diff --git a/hack/oc-mirror.Dockerfile b/hack/oc-mirror.Dockerfile new file mode 100644 index 0000000..c645354 --- /dev/null +++ b/hack/oc-mirror.Dockerfile @@ -0,0 +1,35 @@ +# Container image providing oc-mirror v2 and skopeo for disconnected mirroring tests. +# By default, downloads the official OpenShift release binary from mirror.openshift.com. + +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 + +ARG TARGETARCH +ARG OCP_VERSION=4.18.18 +ARG OC_MIRROR_X86_64_SHA256=b41059474ecfd1ba4ebae3aa7d052ea33f337097d9bea85a4363646c43d1822c +ARG OC_MIRROR_AARCH64_SHA256=7bdb10ea539d9d5e16338eb6df32f9998aca66d39f6156be9e0127a4d9779f64 + +RUN microdnf install -y \ + tar \ + gzip \ + ca-certificates \ + shadow-utils \ + skopeo \ + && microdnf clean all + +RUN set -eux; \ + ARCH="${TARGETARCH:-$(uname -m)}"; \ + case "$ARCH" in \ + x86_64|amd64) ARCH_DIR="x86_64"; ARCHIVE_SHA256="$OC_MIRROR_X86_64_SHA256" ;; \ + aarch64|arm64) ARCH_DIR="aarch64"; ARCHIVE_SHA256="$OC_MIRROR_AARCH64_SHA256" ;; \ + *) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;; \ + esac; \ + URL="https://mirror.openshift.com/pub/openshift-v4/${ARCH_DIR}/clients/ocp/${OCP_VERSION}/oc-mirror.tar.gz"; \ + echo "Downloading oc-mirror from ${URL}..."; \ + curl -fsSLo /tmp/oc-mirror.tar.gz "$URL"; \ + echo "${ARCHIVE_SHA256} /tmp/oc-mirror.tar.gz" | sha256sum -c -; \ + tar -xzf /tmp/oc-mirror.tar.gz -C /usr/local/bin oc-mirror; \ + rm /tmp/oc-mirror.tar.gz; \ + chmod +x /usr/local/bin/oc-mirror; \ + /usr/local/bin/oc-mirror version || true + +ENTRYPOINT ["/usr/local/bin/oc-mirror"] diff --git a/hack/test-disconnected-mirror.sh b/hack/test-disconnected-mirror.sh new file mode 100755 index 0000000..1b01148 --- /dev/null +++ b/hack/test-disconnected-mirror.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Exercise oc-mirror v2's mirror-to-disk and disk-to-mirror path in containers. +# The archive is copied to a fresh workspace before the disconnected phase. + +CONTAINER_TOOL="${CONTAINER_TOOL:-docker}" +OC_MIRROR_IMAGE="${OC_MIRROR_IMAGE:-hyperfleet-oc-mirror:local}" +REGISTRY_IMAGE="${REGISTRY_IMAGE:-docker.io/library/registry:2}" +KEEP_WORKSPACE="${KEEP_WORKSPACE:-false}" + +fail() { + echo "error: $*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "required command '$1' is not installed" +} + +normalize_pullspec() { + local name="$1" + local image="${!name:-}" + image="${image#https://}" + image="${image#http://}" + image="${image#docker://}" + if [[ "$image" =~ :sha256-([0-9a-f]{64})$ ]]; then + image="${image%:sha256-*}@sha256:${BASH_REMATCH[1]}" + fi + printf -v "$name" '%s' "$image" +} + +require_image() { + local name="$1" + normalize_pullspec "$name" + local image="${!name:-}" + [[ "$image" =~ ^[^[:space:]]+@sha256:[0-9a-f]{64}$ ]] || \ + fail "$name must be set to a sha256 digest pullspec" +} + +require_command "$CONTAINER_TOOL" + +OPERATOR_IMAGE="${OPERATOR_IMAGE:-quay.io/redhat-user-workloads/hyperfleet-tenant/hyperfleet/hyperfleet-operator@sha256:31e365d312b6f3d483913d4029eba565abd64ab38a6d117658324afe225f708d}" +API_IMAGE="${API_IMAGE:-quay.io/redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-api@sha256:8533d0d875480f31f5112e454659a095a5d2e993c139a9045a06be6b67b829ca}" +BUNDLE_IMAGE="${BUNDLE_IMAGE:-}" +require_image BUNDLE_IMAGE +require_image OPERATOR_IMAGE +require_image API_IMAGE + +if ! "$CONTAINER_TOOL" image inspect "$OC_MIRROR_IMAGE" >/dev/null 2>&1; then + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + echo "building $OC_MIRROR_IMAGE" + "$CONTAINER_TOOL" build -f "$script_dir/oc-mirror.Dockerfile" -t "$OC_MIRROR_IMAGE" "$script_dir/.." +fi + +workspace="$(mktemp -d "${TMPDIR:-/tmp}/hyperfleet-disconnected-mirror.XXXXXX")" +connected="$workspace/connected" +disconnected="$workspace/disconnected" +mkdir -p "$connected/home" "$disconnected/home" "$disconnected/archive" +network="hyperfleet-disconnected-$$" +registry_container="" + +cleanup() { + [[ -z "$registry_container" ]] || "$CONTAINER_TOOL" rm -f "$registry_container" >/dev/null 2>&1 || true + "$CONTAINER_TOOL" network rm "$network" >/dev/null 2>&1 || true + if [[ "$KEEP_WORKSPACE" == true ]]; then + echo "workspace preserved at $workspace" + else + rm -rf "$workspace" + fi +} +trap cleanup EXIT + +find_auth_file() { + if [[ -n "${REGISTRY_AUTH_FILE:-}" && -f "$REGISTRY_AUTH_FILE" ]]; then + printf '%s\n' "$REGISTRY_AUTH_FILE" + return + fi + local candidate + for candidate in \ + "${XDG_RUNTIME_DIR:-}/containers/auth.json" \ + "${HOME}/.docker/config.json" \ + "${HOME}/.config/containers/auth.json"; do + [[ -f "$candidate" ]] && { printf '%s\n' "$candidate"; return; } + done + return 1 +} + +auth_file="$(find_auth_file || true)" +if [[ -n "$auth_file" ]]; then + cp "$auth_file" "$connected/auth.json" + cp "$auth_file" "$disconnected/auth.json" +fi + +cat >"$connected/imageset-config.yaml" </dev/null +registry_container="$($CONTAINER_TOOL run -d --rm --network "$network" --network-alias registry "$REGISTRY_IMAGE")" +DESTINATION="registry:5000/hyperfleet" + +echo "running disk-to-mirror with archive-only input" +"$CONTAINER_TOOL" run --rm --network "$network" \ + -e HOME=/work/home -v "$disconnected:/work:z" -w /work \ + "$OC_MIRROR_IMAGE" "${auth_args[@]}" --v2 \ + --config imageset-config.yaml --from file://archive \ + --dest-tls-verify=false docker://$DESTINATION + +destination_ref() { + local source="$1" + local repository="${source%@*}" + repository="${repository#*/}" + printf '%s/%s@%s' "$DESTINATION" "$repository" "${source##*@}" +} + +for source in "$BUNDLE_IMAGE" "$OPERATOR_IMAGE" "$API_IMAGE"; do + reference="$(destination_ref "$source")" + echo "checking $reference" + "$CONTAINER_TOOL" run --rm --network "$network" \ + -e HOME=/work/home -v "$disconnected:/work:z" -w /work \ + --entrypoint /usr/bin/skopeo "$OC_MIRROR_IMAGE" \ + inspect --tls-verify=false "docker://$reference" >/dev/null +done + +echo "disconnected mirror transfer passed for bundle, operator, and API digests" diff --git a/hack/verify-related-images/main.go b/hack/verify-related-images/main.go new file mode 100644 index 0000000..c91c951 --- /dev/null +++ b/hack/verify-related-images/main.go @@ -0,0 +1,233 @@ +// Copyright 2026. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// verify-related-images checks the deployable image inventory against the +// manager image, RELATED_IMAGE_* values, and relatedImages in the bundle CSV. +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "regexp" + "sort" + "strings" + + "sigs.k8s.io/yaml" +) + +const relatedImagePrefix = "RELATED_IMAGE_" + +var digestPullspec = regexp.MustCompile(`^\S+@sha256:[0-9a-f]{64}$`) + +type imageEntry struct { + Name string `json:"name"` + Image string `json:"image"` +} + +type inventoryDocument struct { + Images []imageEntry `json:"images"` +} + +type csvDocument struct { + Spec struct { + Install struct { + Spec struct { + Deployments []struct { + Spec struct { + Template struct { + Spec struct { + Containers []container `json:"containers"` + } `json:"spec"` + } `json:"template"` + } `json:"spec"` + } `json:"deployments"` + } `json:"spec"` + } `json:"install"` + RelatedImages []imageEntry `json:"relatedImages"` + } `json:"spec"` +} + +type container struct { + Name string `json:"name"` + Image string `json:"image"` + Env []env `json:"env"` +} + +type env struct { + Name string `json:"name"` + Value string `json:"value"` +} + +func main() { + csvPath := flag.String("csv", "bundle/manifests/hyperfleet-operator.clusterserviceversion.yaml", "path to the bundle CSV") + inventoryPath := flag.String("inventory", "config/deployable-images.yaml", "path to the deployable image inventory") + flag.Parse() + + csvData, err := readFile("CSV", *csvPath) + if err == nil { + err = verifyFiles(csvData, *inventoryPath) + } + if err != nil { + fmt.Fprintf(os.Stderr, "related image verification failed:\n%s\n", err) + os.Exit(1) + } + fmt.Println("related image verification passed") +} + +func verifyFiles(csvData []byte, inventoryPath string) error { + inventoryData, err := readFile("deployable image inventory", inventoryPath) + if err != nil { + return err + } + var inventory inventoryDocument + if err := yaml.Unmarshal(inventoryData, &inventory); err != nil { + return fmt.Errorf("parse deployable image inventory YAML: %w", err) + } + return verifyCSV(csvData, inventory.Images) +} + +func readFile(description, path string) ([]byte, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", description, err) + } + return data, nil +} + +func verifyCSV(data []byte, inventory []imageEntry) error { + var csv csvDocument + if err := yaml.Unmarshal(data, &csv); err != nil { + return fmt.Errorf("parse CSV YAML: %w", err) + } + + problems := validateInventory(inventory) + csvImages, csvProblems := deployableImages(csv) + problems = append(problems, csvProblems...) + problems = append(problems, compareImages("deployable image inventory", inventory, "CSV image sources", csvImages)...) + problems = append(problems, compareImages("CSV image sources", csvImages, "CSV spec.relatedImages", csv.Spec.RelatedImages)...) + return problemError(problems) +} + +func validateInventory(inventory []imageEntry) []string { + if len(inventory) == 0 { + return []string{"deployable image inventory is empty"} + } + problems := []string{} + seenNames := map[string]bool{} + seenImages := map[string]bool{} + for _, entry := range inventory { + if entry.Name == "" || entry.Image == "" { + problems = append(problems, fmt.Sprintf("inventory entry %q has an empty name or image", entry.Name)) + } + if seenNames[entry.Name] { + problems = append(problems, fmt.Sprintf("duplicate deployable image name %q", entry.Name)) + } + seenNames[entry.Name] = true + if seenImages[entry.Image] { + problems = append(problems, fmt.Sprintf("duplicate deployable image %q", entry.Image)) + } + seenImages[entry.Image] = true + if !digestPullspec.MatchString(entry.Image) { + problems = append(problems, fmt.Sprintf("mutable or malformed inventory image %q: %q", entry.Name, entry.Image)) + } + } + return problems +} + +func deployableImages(csv csvDocument) ([]imageEntry, []string) { + var managerImages []string + var images []imageEntry + problems := []string{} + for _, deployment := range csv.Spec.Install.Spec.Deployments { + for _, c := range deployment.Spec.Template.Spec.Containers { + if c.Name == "manager" { + managerImages = append(managerImages, c.Image) + } + for _, variable := range c.Env { + if !strings.HasPrefix(variable.Name, relatedImagePrefix) { + continue + } + suffix := strings.TrimPrefix(variable.Name, relatedImagePrefix) + if suffix == "" { + problems = append(problems, "CSV contains an empty RELATED_IMAGE_ variable name") + continue + } + images = append(images, imageEntry{ + Name: strings.ToLower(strings.ReplaceAll(suffix, "_", "-")), + Image: variable.Value, + }) + } + } + } + + if len(managerImages) != 1 { + problems = append(problems, fmt.Sprintf("expected exactly one manager container, found %d", len(managerImages))) + } else { + images = append([]imageEntry{{Name: "hyperfleet-operator", Image: managerImages[0]}}, images...) + } + return images, problems +} + +func compareImages(leftLabel string, left []imageEntry, rightLabel string, right []imageEntry) []string { + problems := []string{} + leftByName, leftProblems := imageMap(leftLabel, left) + rightByName, rightProblems := imageMap(rightLabel, right) + problems = append(problems, leftProblems...) + problems = append(problems, rightProblems...) + + for name, image := range leftByName { + if actual, ok := rightByName[name]; !ok { + problems = append(problems, fmt.Sprintf("%s is missing %s entry %q", rightLabel, leftLabel, name)) + } else if actual != image { + problems = append(problems, fmt.Sprintf("%s entry %q is %q, expected %q", rightLabel, name, actual, image)) + } + } + for name := range rightByName { + if _, ok := leftByName[name]; !ok { + problems = append(problems, fmt.Sprintf("%s has undeclared entry %q", rightLabel, name)) + } + } + return problems +} + +func imageMap(label string, images []imageEntry) (map[string]string, []string) { + result := make(map[string]string, len(images)) + seenImages := make(map[string]string, len(images)) + problems := []string{} + for _, image := range images { + if previous, ok := result[image.Name]; ok { + problems = append(problems, fmt.Sprintf("%s has duplicate name %q (%q and %q)", label, image.Name, previous, image.Image)) + continue + } + if previous, ok := seenImages[image.Image]; ok { + problems = append(problems, fmt.Sprintf("%s has duplicate image %q (%s and %s)", label, image.Image, previous, image.Name)) + } + result[image.Name] = image.Image + seenImages[image.Image] = image.Name + if !digestPullspec.MatchString(image.Image) { + problems = append(problems, fmt.Sprintf("mutable or malformed %s image %q: %q", label, image.Name, image.Image)) + } + } + return result, problems +} + +func problemError(problems []string) error { + if len(problems) == 0 { + return nil + } + sort.Strings(problems) + return errors.New("- " + strings.Join(problems, "\n- ")) +} diff --git a/hack/verify-related-images/main_test.go b/hack/verify-related-images/main_test.go new file mode 100644 index 0000000..8c6ee36 --- /dev/null +++ b/hack/verify-related-images/main_test.go @@ -0,0 +1,124 @@ +// Copyright 2026. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "strings" + "testing" +) + +var ( + operatorImage = "registry.example.com/hyperfleet-operator@sha256:" + strings.Repeat("1", 64) + apiImage = "registry.example.com/hyperfleet-api@sha256:" + strings.Repeat("2", 64) + extraImage = "registry.example.com/extra@sha256:" + strings.Repeat("3", 64) +) + +func validCSV() string { + return `apiVersion: operators.coreos.com/v1alpha1 +kind: ClusterServiceVersion +spec: + install: + spec: + deployments: + - name: hyperfleet-operator-controller-manager + spec: + template: + spec: + containers: + - name: manager + image: ` + operatorImage + ` + env: + - name: RELATED_IMAGE_HYPERFLEET_API + value: ` + apiImage + ` + relatedImages: + - name: hyperfleet-operator + image: ` + operatorImage + ` + - name: hyperfleet-api + image: ` + apiImage + ` +` +} + +func validInventory() []imageEntry { + return []imageEntry{ + {Name: "hyperfleet-operator", Image: operatorImage}, + {Name: "hyperfleet-api", Image: apiImage}, + } +} + +func TestVerifyCSV(t *testing.T) { + tests := []struct { + name string + mutate func(string) string + wantErr string + }{ + {name: "valid", mutate: func(s string) string { return s }}, + {name: "missing inventory entry", mutate: func(s string) string { return s }, wantErr: "CSV image sources has undeclared entry \"hyperfleet-api\""}, + {name: "stale related image", mutate: func(s string) string { + return strings.Replace(s, " - name: hyperfleet-api\n image: "+apiImage, " - name: extra\n image: "+extraImage, 1) + }, wantErr: "CSV spec.relatedImages has undeclared entry \"extra\""}, + {name: "duplicate related image", mutate: func(s string) string { + return s + " - name: extra\n image: " + apiImage + "\n" + }, wantErr: "CSV spec.relatedImages has duplicate image"}, + {name: "mutable image", mutate: func(s string) string { + return strings.ReplaceAll(s, apiImage, "registry.example.com/hyperfleet-api:latest") + }, wantErr: "mutable or malformed"}, + {name: "mismatched related image", mutate: func(s string) string { + return strings.Replace(s, " - name: hyperfleet-api\n image: "+apiImage, " - name: hyperfleet-api\n image: "+extraImage, 1) + }, wantErr: "CSV spec.relatedImages entry \"hyperfleet-api\""}, + {name: "missing operand env", mutate: func(s string) string { + env := " env:\n - name: RELATED_IMAGE_HYPERFLEET_API\n value: " + apiImage + "\n" + return strings.Replace(s, env, "", 1) + }, wantErr: "CSV image sources is missing deployable image inventory entry \"hyperfleet-api\""}, + {name: "undeclared CSV operand", mutate: func(s string) string { + return strings.Replace(s, "RELATED_IMAGE_HYPERFLEET_API", "RELATED_IMAGE_EXTRA", 1) + }, wantErr: "CSV image sources has undeclared entry \"extra\""}, + {name: "mutable manager image", mutate: func(s string) string { + return strings.Replace(s, operatorImage, "registry.example.com/hyperfleet-operator:latest", 2) + }, wantErr: "mutable or malformed"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inventory := validInventory() + if tt.name == "missing inventory entry" { + inventory = inventory[:1] + } + err := verifyCSV([]byte(tt.mutate(validCSV())), inventory) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("verifyCSV() error = %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("verifyCSV() error = %v, want substring %q", err, tt.wantErr) + } + }) + } +} + +func TestVerifyCSVRejectsDuplicateAndMalformedInventory(t *testing.T) { + inventory := validInventory() + inventory = append(inventory, imageEntry{Name: "hyperfleet-api", Image: extraImage}) + if err := verifyCSV([]byte(validCSV()), inventory); err == nil || !strings.Contains(err.Error(), "duplicate deployable image name") { + t.Fatalf("verifyCSV() error = %v, want duplicate inventory name", err) + } + + inventory = validInventory() + inventory[1].Image = "registry.example.com/hyperfleet-api:latest" + if err := verifyCSV([]byte(validCSV()), inventory); err == nil || !strings.Contains(err.Error(), "mutable or malformed inventory image") { + t.Fatalf("verifyCSV() error = %v, want malformed inventory image", err) + } +} diff --git a/internal/component/api/api.go b/internal/component/api/api.go index 52981af..7b83952 100644 --- a/internal/component/api/api.go +++ b/internal/component/api/api.go @@ -27,8 +27,8 @@ import ( ) // DefaultImage is the compiled-in fallback image used when the operator is not -// given RELATED_IMAGE_HYPERFLEET_API. Production deployments override it with a -// digest-pinned image via that env var (OLM relatedImages convention). +// given RELATED_IMAGE_HYPERFLEET_API. It is digest-pinned so plain-manifest and +// local deployments use the same immutable image contract as OLM installs. // // Must be v0.3.0 or later: config.yaml renders entities (pkg/registry) and the // multi-issuer server.jwt.configs list, neither of which exist in v0.2.x's @@ -50,7 +50,7 @@ import ( // prefix there, unlike the hyperfleet-api git tag). openshift-hyperfleet/hyperfleet-api // is a legacy pre-Konflux registry that stopped receiving pushes after v0.2.1 // and never got a v0.4.0 image at all (PR #6 review comment r3905519856). -const DefaultImage = "quay.io/redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-api:0.4.0" +const DefaultImage = "quay.io/redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-api@sha256:8533d0d875480f31f5112e454659a095a5d2e993c139a9045a06be6b67b829ca" // Component renders the HyperFleet API operand. It satisfies the bundle.Component // contract structurally (no import of internal/bundle, avoiding an import cycle: