diff --git a/.tekton/hyperfleet-operator-bundle-push.yaml b/.tekton/hyperfleet-operator-bundle-push.yaml index 3757c53..37230c0 100644 --- a/.tekton/hyperfleet-operator-bundle-push.yaml +++ b/.tekton/hyperfleet-operator-bundle-push.yaml @@ -12,7 +12,8 @@ metadata: == "main" && (".tekton/hyperfleet-operator-bundle-push.yaml".pathChanged() || "bundle.Dockerfile".pathChanged() || - "config/***".pathChanged()) + "config/***".pathChanged() || + "validators/related-images/**".pathChanged()) labels: appstudio.openshift.io/application: hyperfleet appstudio.openshift.io/component: hyperfleet-operator-bundle @@ -228,6 +229,7 @@ spec: - $(params.build-args[*]) - KUSTOMIZE_VARIANT=config/manifests/prod - BUNDLE_VERSION=0.0.1 + - VALIDATE_RELATED_IMAGES=true - name: BUILD_ARGS_FILE value: $(params.build-args-file) - name: PRIVILEGED_NESTED diff --git a/.tekton/hyperfleet-operator-push.yaml b/.tekton/hyperfleet-operator-push.yaml index 7248682..e53be60 100644 --- a/.tekton/hyperfleet-operator-push.yaml +++ b/.tekton/hyperfleet-operator-push.yaml @@ -15,6 +15,7 @@ metadata: x.matches('^bundle\\.Dockerfile$') || x.matches('^config/manifests/prod/') || x.matches('^hack/test-disconnected-mirror\\.sh$') + || x.matches('^validators/') || x.matches('^\\.tekton/hyperfleet-operator-bundle-push\\.yaml$') || x.matches('^catalog/') || x.matches('^catalog\\.Dockerfile$') diff --git a/Makefile b/Makefile index 2b3ca20..5dd617f 100644 --- a/Makefile +++ b/Makefile @@ -120,23 +120,10 @@ cleanup-test-e2e: ## Tear down the Kind cluster used for e2e tests ##@ Lint -.PHONY: verify-related-images -verify-related-images: ## Verify a final built bundle CSV (CSV_FILE is required). - @test -n "$(CSV_FILE)" || { echo "Set CSV_FILE to the CSV extracted from the built bundle"; exit 1; } - go run ./hack/verify-related-images -csv "$(CSV_FILE)" - .PHONY: lint lint: ## Run golangci-lint. $(GOLANGCI_LINT) run -.PHONY: verify-bundle-related-images -verify-bundle-related-images: ## Transform the repository bundle CSV and verify its related images. - @set -euo pipefail; \ - yq_path=$$($(call gotool,-n yq)); \ - YQ="$$yq_path" bash ./hack/verify-bundle-related-images.sh; \ - PATH="$$(dirname "$$yq_path"):$$PATH" go test -tags integration ./hack/verify-related-images; \ - YQ="$$yq_path" bash ./hack/test-verify-bundle-related-images.sh - .PHONY: lint-fix lint-fix: ## Run golangci-lint linter and perform fixes $(GOLANGCI_LINT) run --fix @@ -351,6 +338,8 @@ endif # To override the operator and API images: # make bundle-build RELATED_IMAGE_HYPERFLEET_OPERATOR= RELATED_IMAGE_HYPERFLEET_API= KUSTOMIZE_VARIANT ?= config/manifests/dev +# For dev builds - unset validation of related images +VALIDATE_RELATED_IMAGES ?= false .PHONY: bundle-build bundle-build: ## Builds the bundle and bundle image. cat config/manifests/dev/patch-images.yaml | envsubst > config/manifests/dev/kustomization.yaml @@ -360,6 +349,7 @@ bundle-build: ## Builds the bundle and bundle image. --build-arg CHANNELS=$(CHANNELS) \ --build-arg DEFAULT_CHANNEL=$(DEFAULT_CHANNEL) \ --build-arg KUSTOMIZE_VARIANT=$(KUSTOMIZE_VARIANT) \ + --build-arg VALIDATE_RELATED_IMAGES=$(VALIDATE_RELATED_IMAGES) \ --build-arg APP_VERSION=$(APP_VERSION) \ -t $(BUNDLE_IMG) . diff --git a/bundle.Dockerfile b/bundle.Dockerfile index 9e3db04..03edb27 100644 --- a/bundle.Dockerfile +++ b/bundle.Dockerfile @@ -1,24 +1,36 @@ +FROM registry.access.redhat.com/ubi9/go-toolset:9.8-1789040808 AS validator + +WORKDIR /workdir +COPY validators/ ./validators/ +COPY go.mod go.mod +COPY go.sum go.sum + +RUN go build -o ./bin/related-images-validator ./validators/related-images/ + FROM quay.io/konflux-ci/operator-sdk-builder:latest@sha256:bd34ca58b2d08e8ee3b9cdf46b32f69173084ca09c1d3aba47285e2c35b4d1fc AS builder WORKDIR /workdir COPY config/ ./config/ +COPY --from=validator /workdir/bin/related-images-validator ./related-images-validator -# Specify the kustomize variant, either bases/kustomization.yaml or prod/kustomization.yaml -# prod/kustomization.yaml gets image update references from konflux. +# Specify the kustomize variant, either config/manifests/dev or config/manifests/prod ARG KUSTOMIZE_VARIANT=config/manifests/dev -# ARG KUSTOMIZE_VARIANT=config/manifests/prod for konflux builds RUN kustomize build /workdir/${KUSTOMIZE_VARIANT} > /workdir/manifests.yaml ARG CHANNELS=stable ARG DEFAULT_CHANNEL=stable ARG BUNDLE_VERSION=0.0.1 - RUN mkdir -p /workdir/bundle RUN cat manifests.yaml | operator-sdk generate bundle -q --version ${BUNDLE_VERSION} \ --channels=${CHANNELS} --default-channel=${DEFAULT_CHANNEL} \ --package=hyperfleet-operator && \ operator-sdk bundle validate ./bundle --select-optional name=operatorhubv2 +ARG VALIDATE_RELATED_IMAGES=true +RUN if [ "$VALIDATE_RELATED_IMAGES" = "true" ]; then \ + ./related-images-validator -csv bundle/manifests/*.clusterserviceversion.yaml; \ + fi + FROM scratch ARG CHANNELS=stable diff --git a/hack/test-verify-bundle-related-images.sh b/hack/test-verify-bundle-related-images.sh deleted file mode 100644 index 8f9729a..0000000 --- a/hack/test-verify-bundle-related-images.sh +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Negative checks run through the real bundle-image gate with temporary packaging -# inputs, rather than only testing hand-written verifier CSV fixtures. -root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -YQ="${YQ:-yq}" -export YQ -workspace="$(mktemp -d "${TMPDIR:-/tmp}/hyperfleet-bundle-test.XXXXXX")" -trap 'rm -rf "$workspace"' EXIT -mkdir -p "$workspace/bundle/manifests" "$workspace/hack/bundle" -csv="$workspace/bundle/manifests/hyperfleet-operator.clusterserviceversion.yaml" - -reset_fixture() { - cp "$root/bundle/manifests/hyperfleet-operator.clusterserviceversion.yaml" "$csv" - cp "$root/bundle.konflux.Dockerfile" "$workspace/bundle.konflux.Dockerfile" - cp "$root/hack/bundle/update_bundle.sh" "$workspace/hack/bundle/update_bundle.sh" -} - -bundle_image_args() { - sed -n 's/^ARG \(HYPERFLEET_[A-Z0-9_]*_IMAGE_PULLSPEC\)="[^"]*"$/\1/p' "$1" -} - -load_bundle_image_pullspecs() { - local image_arg value image_arg_count=0 - - while IFS= read -r image_arg; do - value="$(sed -n "s/^ARG ${image_arg}=\"\\([^\"]*\\)\"$/\\1/p" "$workspace/bundle.konflux.Dockerfile")" - [[ -n "$value" ]] || { - echo "missing default for bundle image argument: $image_arg" >&2 - exit 1 - } - export "$image_arg=$value" - ((image_arg_count += 1)) - done < <(bundle_image_args "$workspace/bundle.konflux.Dockerfile") - ((image_arg_count > 0)) || { - echo "bundle Dockerfile has no image pullspec arguments" >&2 - exit 1 - } -} - -expect_failure() { - local description="$1" - local expected_message="$2" - - if bash "$root/hack/verify-bundle-related-images.sh" "$workspace" >"$workspace/result.log" 2>&1; then - echo "bundle-image gate unexpectedly accepted: $description" >&2 - exit 1 - fi - if ! grep -Fq -- "$expected_message" "$workspace/result.log"; then - echo "bundle-image gate returned the wrong diagnostic for: $description" >&2 - cat "$workspace/result.log" >&2 - exit 1 - fi - echo "bundle-image negative check passed: $description" -} - -reset_fixture -"$YQ" eval -i ' - del(.spec.install.spec.deployments[].spec.template.spec.containers[].env[] | - select(.name == "RELATED_IMAGE_HYPERFLEET_API")) | - del(.spec.relatedImages[] | select(.name == "hyperfleet-api")) -' "$csv" -expect_failure \ - "API missing from both override and relatedImages" \ - "CSV is missing exactly one RELATED_IMAGE_HYPERFLEET_API runtime override" - -reset_fixture -load_bundle_image_pullspecs -CSV_FILE="$csv" bash "$workspace/hack/bundle/update_bundle.sh" >/dev/null -"$YQ" eval -r '.spec.relatedImages[]?.name | select(. != null)' "$csv" \ - >"$workspace/related-image-names" - -related_image_count=0 -while IFS= read -r related_image; do - ((related_image_count += 1)) - reset_fixture - # Simulate a regression in bundle packaging that drops each declared image - # after update_bundle.sh has populated the final CSV. - export RELATED_IMAGE_NAME="$related_image" - printf '\n"$YQ" eval -i '\''del(.spec.relatedImages[] | select(.name == strenv(RELATED_IMAGE_NAME)))'\'' "$CSV_FILE"\n' \ - >>"$workspace/hack/bundle/update_bundle.sh" - expect_failure \ - "$related_image missing from the transformed bundle CSV" \ - "CSV spec.relatedImages is missing CSV image sources entry \"$related_image\"" - unset RELATED_IMAGE_NAME -done <"$workspace/related-image-names" -((related_image_count > 0)) || { - echo "bundle CSV has no relatedImages to test" >&2 - exit 1 -} - -image_arg_count=0 -while IFS= read -r image_arg; do - [[ "$image_arg" =~ ^HYPERFLEET_[A-Z0-9_]+_IMAGE_PULLSPEC$ ]] || { - echo "unexpected bundle image argument: $image_arg" >&2 - exit 1 - } - ((image_arg_count += 1)) - reset_fixture - sed -E "s|^ARG ${image_arg}=\"[^\"]*\"$|ARG ${image_arg}=\"example.com/invalid:latest\"|" \ - "$root/bundle.konflux.Dockerfile" >"$workspace/bundle.konflux.Dockerfile" - expect_failure \ - "mutable $image_arg" \ - "$image_arg must be a non-empty sha256 digest pullspec" -done < <(bundle_image_args "$root/bundle.konflux.Dockerfile") -((image_arg_count > 0)) || { - echo "bundle Dockerfile has no image pullspec arguments to test" >&2 - exit 1 -} diff --git a/hack/verify-bundle-related-images.sh b/hack/verify-bundle-related-images.sh deleted file mode 100644 index b233f4a..0000000 --- a/hack/verify-bundle-related-images.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Check the same CSV transformation used by the bundle build without -# modifying generated manifests or requiring a container runtime in PR CI. -root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source_root="${1:-$root}" -YQ="${YQ:-yq}" -export YQ -command -v "$YQ" >/dev/null 2>&1 || { echo "required command: $YQ" >&2; exit 1; } - -# Read defaults as data, never source/eval a Dockerfile. Duplicate or missing -# declarations fail the digest checks in update_bundle.sh. -for variable in HYPERFLEET_OPERATOR_IMAGE_PULLSPEC HYPERFLEET_API_IMAGE_PULLSPEC; do - value="$(sed -n "s/^ARG ${variable}=\"\([^\"]*\)\"$/\1/p" "$source_root/bundle.konflux.Dockerfile")" - export "$variable=$value" -done - -workspace="$(mktemp -d "${TMPDIR:-/tmp}/hyperfleet-bundle-check.XXXXXX")" -trap 'rm -rf "$workspace"' EXIT -cp "$source_root/bundle/manifests/hyperfleet-operator.clusterserviceversion.yaml" "$workspace/bundle.yaml" -export CSV_FILE="$workspace/bundle.yaml" -bash "$source_root/hack/bundle/update_bundle.sh" >/dev/null -cd "$root" -go run ./hack/verify-related-images -csv "$CSV_FILE" diff --git a/hack/verify-related-images/integration_test.go b/hack/verify-related-images/integration_test.go deleted file mode 100644 index 60e1436..0000000 --- a/hack/verify-related-images/integration_test.go +++ /dev/null @@ -1,154 +0,0 @@ -// 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. - -//go:build integration - -package main - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "sigs.k8s.io/yaml" -) - -// Exercise the bundle patching entrypoint with images that differ from the -// source CSV. This models the Konflux build boundary without registry access. -func TestBundleBuildImageUpdate(t *testing.T) { - yq, err := exec.LookPath("yq") - if err != nil { - t.Skip("yq is required for the bundle build integration test") - } - - t.Run("updates image references", func(t *testing.T) { - csvPath := writeCSV(t, validCSV()) - output, err := runBundleUpdate(t, yq, csvPath, extraImage, operatorImage) - if err != nil { - t.Fatalf("%v: %s", err, output) - } - data, err := os.ReadFile(csvPath) - if err != nil { - t.Fatal(err) - } - if err := verifyCSV(data); err != nil { - t.Fatal(err) - } - - assertBundleImageReferences(t, data, extraImage, operatorImage) - if strings.Contains(string(data), apiImage) { - t.Fatal("old source digest survived bundle build") - } - }) - - t.Run("rejects missing runtime override", func(t *testing.T) { - missing := strings.Replace(validCSV(), "RELATED_IMAGE_HYPERFLEET_API", "UNRELATED_SETTING", 1) - csvPath := writeCSV(t, missing) - output, err := runBundleUpdate(t, yq, csvPath, extraImage, operatorImage) - if err == nil { - t.Fatalf("bundle build accepted missing API override: %s", output) - } - if got := string(output); !strings.Contains(got, "CSV is missing exactly one RELATED_IMAGE_HYPERFLEET_API runtime override") { - t.Fatalf("missing API override error = %q", got) - } - }) - - t.Run("rejects tagged manager image", func(t *testing.T) { - csvPath := writeCSV(t, validCSV()) - rejectedPullspec := "registry.example.com/operator:latest" - output, err := runBundleUpdate(t, yq, csvPath, rejectedPullspec, apiImage) - if err == nil { - t.Fatalf("bundle build accepted tagged manager image: %s", output) - } - if got := string(output); !strings.Contains(got, rejectedPullspec) { - t.Fatalf("tagged image error = %q, want rejected pullspec %q", got, rejectedPullspec) - } - }) -} - -func assertBundleImageReferences(t *testing.T, data []byte, operatorPullspec, apiPullspec string) { - t.Helper() - var csv csvDocument - if err := yaml.Unmarshal(data, &csv); err != nil { - t.Fatalf("parse updated CSV: %v", err) - } - var metadata struct { - Metadata struct { - Annotations map[string]string `json:"annotations"` - } `json:"metadata"` - } - if err := yaml.Unmarshal(data, &metadata); err != nil { - t.Fatalf("parse updated CSV metadata: %v", err) - } - - var managerImage, apiOverride string - for _, deployment := range csv.Spec.Install.Spec.Deployments { - for _, container := range deployment.Spec.Template.Spec.Containers { - if container.Name != "manager" { - continue - } - managerImage = container.Image - for _, variable := range container.Env { - if variable.Name == "RELATED_IMAGE_HYPERFLEET_API" { - apiOverride = variable.Value - } - } - } - } - var operatorRelatedImage, apiRelatedImage string - for _, image := range csv.Spec.RelatedImages { - switch image.Name { - case "hyperfleet-operator": - operatorRelatedImage = image.Image - case "hyperfleet-api": - apiRelatedImage = image.Image - } - } - - for _, check := range []struct { - location string - got string - want string - }{ - {"manager image", managerImage, operatorPullspec}, - {"metadata.annotations.containerImage", metadata.Metadata.Annotations["containerImage"], operatorPullspec}, - {"related image hyperfleet-operator", operatorRelatedImage, operatorPullspec}, - {"RELATED_IMAGE_HYPERFLEET_API", apiOverride, apiPullspec}, - {"related image hyperfleet-api", apiRelatedImage, apiPullspec}, - } { - if check.got != check.want { - t.Errorf("%s = %q, want requested pullspec %q", check.location, check.got, check.want) - } - } -} - -func writeCSV(t *testing.T, contents string) string { - t.Helper() - path := filepath.Join(t.TempDir(), "bundle.csv") - if err := os.WriteFile(path, []byte(contents), 0600); err != nil { - t.Fatal(err) - } - return path -} - -func runBundleUpdate(t *testing.T, yq, csvPath, operatorPullspec, apiPullspec string) ([]byte, error) { - t.Helper() - cmd := exec.CommandContext(t.Context(), "bash", "../bundle/update_bundle.sh") - cmd.Env = append(os.Environ(), "YQ="+yq, "CSV_FILE="+csvPath, - "HYPERFLEET_OPERATOR_IMAGE_PULLSPEC="+operatorPullspec, - "HYPERFLEET_API_IMAGE_PULLSPEC="+apiPullspec) - return cmd.CombinedOutput() -} diff --git a/hack/verify-related-images/main.go b/hack/verify-related-images/main.go deleted file mode 100644 index 3f90166..0000000 --- a/hack/verify-related-images/main.go +++ /dev/null @@ -1,210 +0,0 @@ -// 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 validates image metadata in a final, built bundle CSV. -// It deliberately does not compare bundle image references to development manifests. -package main - -import ( - _ "crypto/sha256" // Register SHA-256 for go-digest validation. - "errors" - "flag" - "fmt" - "os" - "slices" - "strings" - - "github.com/distribution/reference" - "github.com/openshift-hyperfleet/hyperfleet-operator/internal/component/api" - "sigs.k8s.io/yaml" -) - -const relatedImagePrefix = "RELATED_IMAGE_" - -type imageEntry struct { - Name string `json:"name"` - Image string `json:"image"` -} - -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", "", "required: CSV extracted from the built bundle") - flag.Parse() - if *csvPath == "" { - fmt.Fprintln(os.Stderr, - "provide -csv pointing to the final built bundle CSV; development manifests are not bundle inputs") - os.Exit(1) - } - data, err := os.ReadFile(*csvPath) - if err == nil { - err = verifyCSV(data) - } - if err != nil { - fmt.Fprintf(os.Stderr, "related image verification failed:\n%v\n", err) - os.Exit(1) - } - fmt.Println("bundle related image verification passed") -} - -func verifyCSV(data []byte) error { - var csv csvDocument - if err := yaml.Unmarshal(data, &csv); err != nil { - return fmt.Errorf("parse CSV YAML: %w", err) - } - images, problems := deployableImages(csv) - // Require the same override consumed by the runtime. Absence is an error - // even if relatedImages also omits the API: otherwise runtime uses a fallback - // which the bundle mirroring metadata does not describe. - apiName := strings.ToLower(strings.ReplaceAll(strings.TrimPrefix(api.RelatedImageEnv, relatedImagePrefix), "_", "-")) - if !slices.ContainsFunc(images, func(entry imageEntry) bool { return entry.Name == apiName }) { - problems = append(problems, "CSV manager is missing required runtime override "+api.RelatedImageEnv) - } - problems = append(problems, - compareImages("CSV image sources", images, "CSV spec.relatedImages", csv.Spec.RelatedImages)...) - return problemError(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) - } - if c.Name != "manager" { - continue - } - 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 !isSHA256DigestPullspec(image.Image) { - problems = append(problems, fmt.Sprintf("mutable or malformed %s image %q: %q", label, image.Name, image.Image)) - } - } - return result, problems -} - -func isSHA256DigestPullspec(image string) bool { - named, err := reference.ParseNormalizedNamed(image) - if err != nil { - return false - } - digested, ok := named.(reference.Digested) - if !ok { - return false - } - digest := digested.Digest() - return digest.Algorithm().String() == "sha256" && digest.Validate() == nil -} - -func problemError(problems []string) error { - if len(problems) == 0 { - return nil - } - slices.Sort(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 deleted file mode 100644 index 2c66b35..0000000 --- a/hack/verify-related-images/main_test.go +++ /dev/null @@ -1,95 +0,0 @@ -// 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 TestVerifyBuiltBundleCSV(t *testing.T) { - tests := []struct { - name string - mutate func(string) string - want string - }{ - {"valid bundle", func(s string) string { return s }, ""}, - {"different bundle", func(s string) string { return strings.ReplaceAll(s, apiImage, extraImage) }, ""}, - {"stale related image", func(s string) string { - return strings.Replace(s, " image: "+apiImage, " image: "+extraImage, 1) - }, "expected"}, - {"tagged bundle image", func(s string) string { - return strings.ReplaceAll(s, apiImage, "registry.example.com/api:latest") - }, "mutable or malformed"}, - {"URL-style bundle image", func(s string) string { - return strings.ReplaceAll(s, apiImage, "https://registry.example.com/api@sha256:"+strings.Repeat("4", 64)) - }, "mutable or malformed"}, - {"duplicate", func(s string) string { - return s + " - name: hyperfleet-api\n image: " + apiImage + "\n" - }, "duplicate name"}, - {"missing API everywhere", func(s string) string { - env := " env:\n - name: RELATED_IMAGE_HYPERFLEET_API\n" + - " value: " + apiImage + "\n" - s = strings.Replace(s, env, "", 1) - return strings.Replace(s, " - name: hyperfleet-api\n image: "+apiImage+"\n", "", 1) - }, "missing required runtime override"}, - {"missing manager", func(s string) string { - return strings.Replace(s, "name: manager", "name: other", 1) - }, "expected exactly one manager"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := verifyCSV([]byte(tt.mutate(validCSV()))) - if tt.want == "" { - if err != nil { - t.Fatal(err) - } - } else if err == nil || !strings.Contains(err.Error(), tt.want) { - t.Fatalf("error = %v, want %q", err, tt.want) - } - }) - } -} diff --git a/validators/related-images/main.go b/validators/related-images/main.go new file mode 100644 index 0000000..c5f52a8 --- /dev/null +++ b/validators/related-images/main.go @@ -0,0 +1,265 @@ +// 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 ( + _ "crypto/sha256" + "encoding/json" + "flag" + "fmt" + "os" + "slices" + "strings" + + "github.com/distribution/reference" + "sigs.k8s.io/yaml" +) + +const ( + relatedImagePrefix = "RELATED_IMAGE_" + validatorName = "related-images" +) + +// operator-sdk external validator JSON output types +type manifestResult struct { + Name string `json:"name"` + Errors []validationMsg `json:"errors"` + Warnings []validationMsg `json:"warnings"` +} + +type validationMsg struct { + Type string `json:"type"` + Level string `json:"level"` + Field string `json:"field,omitempty"` + Detail string `json:"detail"` +} + +type imageEntry struct { + Name string `json:"name"` + Image string `json:"image"` +} + +type csvDocument struct { + Spec struct { + Install struct { + Spec struct { + Deployments []deployment `json:"deployments"` + } `json:"spec"` + } `json:"install"` + RelatedImages []imageEntry `json:"relatedImages"` + } `json:"spec"` +} + +type deployment struct { + Spec struct { + Template struct { + Spec struct { + Containers []container `json:"containers"` + InitContainers []container `json:"initContainers"` + } `json:"spec"` + } `json:"template"` + } `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", "", "path to a CSV file (standalone mode)") + flag.Parse() + + var result manifestResult + if *csvPath != "" { + data, err := os.ReadFile(*csvPath) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to read CSV: %v\n", err) + os.Exit(1) + } + result = validateCSVData(data) + } else if flag.NArg() > 0 { + result = validate(flag.Arg(0)) + } else { + fmt.Fprintf(os.Stderr, "usage: %s \n %s -csv \n", os.Args[0], os.Args[0]) + os.Exit(1) + } + + out, err := json.MarshalIndent(result, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR marshaling result: %v\n", err) + os.Exit(1) + } + fmt.Println(string(out)) + + // operator-sdk reads errors from JSON output, so exit 0 is fine there. + // Standalone -csv mode needs a non-zero exit code for CI. + if *csvPath != "" && len(result.Errors) > 0 { + os.Exit(1) + } +} + +func validate(bundleRoot string) manifestResult { + csvPath, err := findCSV(bundleRoot) + if err != nil { + return manifestResult{ + Name: validatorName, + Errors: []validationMsg{errMsg("", fmt.Sprintf("failed to find CSV: %v", err))}, + } + } + + data, err := os.ReadFile(csvPath) + if err != nil { + return manifestResult{ + Name: validatorName, + Errors: []validationMsg{errMsg("", fmt.Sprintf("failed to read CSV: %v", err))}, + } + } + + return validateCSVData(data) +} + +func validateCSVData(data []byte) manifestResult { + result := manifestResult{Name: validatorName} + + var csv csvDocument + if err := yaml.Unmarshal(data, &csv); err != nil { + result.Errors = append(result.Errors, errMsg("", fmt.Sprintf("failed to parse CSV: %v", err))) + return result + } + + containerImages := validateContainerImages(&result, csv.Spec.Install.Spec.Deployments) + relatedImages := validateRelatedImages(&result, csv.Spec.RelatedImages, containerImages) + validateEnvVars(&result, csv.Spec.Install.Spec.Deployments, relatedImages) + + for image, matched := range containerImages { + if !matched { + result.Errors = append(result.Errors, errMsg("spec.relatedImages", + fmt.Sprintf("container image not in relatedImages: %q", image))) + } + } + return result +} + +// validateContainerImages checks that each container image is a sha256 digest pullspec. +func validateContainerImages(result *manifestResult, deployments []deployment) map[string]bool { + containerImages := make(map[string]bool) + for _, dep := range deployments { + allContainers := append(dep.Spec.Template.Spec.Containers, dep.Spec.Template.Spec.InitContainers...) + for _, c := range allContainers { + containerImages[c.Image] = false + if !isSHA256DigestPullspec(c.Image) { + result.Errors = append(result.Errors, errMsg("relatedImages", + fmt.Sprintf("image reference is not a sha256 digest: %v", c.Image))) + } + } + } + return containerImages +} + +// validateRelatedImages checks for duplicate entries and non-sha256 digests in spec.relatedImages. +func validateRelatedImages( + result *manifestResult, images []imageEntry, containerImages map[string]bool, +) map[string]string { + relatedImages := make(map[string]string) + for _, image := range images { + if !isSHA256DigestPullspec(image.Image) { + result.Errors = append(result.Errors, errMsg("relatedImages", + fmt.Sprintf("image reference is not a sha256 digest: %v", image.Image))) + } + if _, ok := relatedImages[image.Image]; !ok { + relatedImages[image.Image] = image.Name + if _, ok := containerImages[image.Image]; ok { + containerImages[image.Image] = true + } + } else { + result.Errors = append(result.Errors, errMsg("relatedImages", + fmt.Sprintf("duplicate value of image: %v", image.Image))) + } + } + return relatedImages +} + +// validateEnvVars checks that RELATED_IMAGE_ env vars use sha256 digests, are not duplicated +// within a container, and have a corresponding entry in spec.relatedImages. +func validateEnvVars(result *manifestResult, deployments []deployment, relatedImages map[string]string) { + for _, dep := range deployments { + allContainers := append(dep.Spec.Template.Spec.Containers, dep.Spec.Template.Spec.InitContainers...) + for _, c := range allContainers { + envRelatedImages := []string{} + for _, e := range c.Env { + if strings.HasPrefix(e.Name, relatedImagePrefix) { + if !isSHA256DigestPullspec(e.Value) { + result.Errors = append(result.Errors, errMsg("relatedImages", + fmt.Sprintf("image reference is not a sha256 digest: %v", e.Value))) + } + if slices.Contains(envRelatedImages, e.Name) { + result.Errors = append(result.Errors, + errMsg("relatedImages", + fmt.Sprintf("duplicate env var %s in container %s", e.Name, c.Name))) + continue + } + envRelatedImages = append(envRelatedImages, e.Name) + if _, ok := relatedImages[e.Value]; !ok { + result.Errors = append(result.Errors, + errMsg("relatedImages", + fmt.Sprintf("env var %s not found in spec.relatedImages (value: %s)", e.Name, e.Value))) + } + } + } + } + } +} + +func isSHA256DigestPullspec(image string) bool { + named, err := reference.ParseNormalizedNamed(image) + if err != nil { + return false + } + digested, ok := named.(reference.Digested) + if !ok { + return false + } + digest := digested.Digest() + return digest.Algorithm().String() == "sha256" && digest.Validate() == nil +} + +func findCSV(bundleRoot string) (string, error) { + manifestsDir := bundleRoot + "/manifests" + entries, err := os.ReadDir(manifestsDir) + if err != nil { + return "", fmt.Errorf("reading manifests directory: %w", err) + } + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".clusterserviceversion.yaml") { + return manifestsDir + "/" + e.Name(), nil + } + } + return "", fmt.Errorf("no CSV found in manifests directory") +} + +func errMsg(field, detail string) validationMsg { + return validationMsg{ + Type: "FailedValidation", + Level: "error", + Field: field, + Detail: detail, + } +} diff --git a/validators/related-images/main_test.go b/validators/related-images/main_test.go new file mode 100644 index 0000000..2253e4e --- /dev/null +++ b/validators/related-images/main_test.go @@ -0,0 +1,206 @@ +// 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 ( + "os" + "path/filepath" + "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 TestVerifyBuiltBundleCSV(t *testing.T) { + tests := []struct { + name string + mutate func(string) string + want string + }{ + {"valid bundle", func(s string) string { return s }, ""}, + {"different bundle", func(s string) string { return strings.ReplaceAll(s, apiImage, extraImage) }, ""}, + {"stale related image", func(s string) string { + return strings.Replace(s, " image: "+apiImage, " image: "+extraImage, 1) + }, "not found in spec.relatedImages"}, + {"tagged bundle image", func(s string) string { + return strings.ReplaceAll(s, apiImage, "registry.example.com/api:latest") + }, "not a sha256 digest"}, + {"URL-style bundle image", func(s string) string { + return strings.ReplaceAll(s, apiImage, "https://registry.example.com/api@sha256:"+strings.Repeat("4", 64)) + }, "not a sha256 digest"}, + {"duplicate relatedImage", func(s string) string { + return s + " - name: extra\n image: " + apiImage + "\n" + }, "duplicate value of image"}, + {"same env in two containers is valid", func(s string) string { + sidecar := "\n - name: sidecar\n" + + " image: " + operatorImage + "\n" + + " env:\n" + + " - name: RELATED_IMAGE_HYPERFLEET_API\n" + + " value: " + apiImage + return strings.Replace(s, " relatedImages:", sidecar+"\n relatedImages:", 1) + }, ""}, + {"duplicate env with unknown value", func(s string) string { + dup := "\n - name: RELATED_IMAGE_HYPERFLEET_API\n" + + " value: " + extraImage + return strings.Replace(s, " relatedImages:", dup+"\n relatedImages:", 1) + }, "duplicate env var"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := validateCSVData([]byte(tt.mutate(validCSV()))) + if tt.want == "" { + if len(result.Errors) > 0 { + t.Fatalf("expected no errors, got: %v", result.Errors) + } + } else { + found := false + for _, e := range result.Errors { + if strings.Contains(e.Detail, tt.want) { + found = true + break + } + } + if !found { + t.Fatalf("expected error containing %q, got: %v", tt.want, result.Errors) + } + } + }) + } +} + +func TestValidate(t *testing.T) { + t.Run("missing directory", func(t *testing.T) { + result := validate("/nonexistent/path") + if len(result.Errors) != 1 { + t.Fatalf("expected 1 error, got %d", len(result.Errors)) + } + if !strings.Contains(result.Errors[0].Detail, "failed to find CSV") { + t.Fatalf("unexpected error: %s", result.Errors[0].Detail) + } + }) + + t.Run("no CSV in manifests", func(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "manifests"), 0o755); err != nil { + t.Fatal(err) + } + result := validate(dir) + if len(result.Errors) != 1 { + t.Fatalf("expected 1 error, got %d", len(result.Errors)) + } + if !strings.Contains(result.Errors[0].Detail, "no CSV found") { + t.Fatalf("unexpected error: %s", result.Errors[0].Detail) + } + }) + + t.Run("valid bundle directory", func(t *testing.T) { + dir := t.TempDir() + manifests := filepath.Join(dir, "manifests") + if err := os.MkdirAll(manifests, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(manifests, "test.clusterserviceversion.yaml"), + []byte(validCSV()), 0o644, + ); err != nil { + t.Fatal(err) + } + result := validate(dir) + if len(result.Errors) > 0 { + t.Fatalf("expected no errors, got: %v", result.Errors) + } + }) +} + +func TestFindCSV(t *testing.T) { + t.Run("returns error for missing dir", func(t *testing.T) { + got, err := findCSV("/nonexistent") + if err == nil { + t.Fatal("expected error, got nil") + } + if got != "" { + t.Fatalf("expected empty path, got %q", got) + } + }) + + t.Run("returns error when no CSV exists", func(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "manifests"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(dir, "manifests", "crd.yaml"), []byte("x"), 0o644, + ); err != nil { + t.Fatal(err) + } + got, err := findCSV(dir) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "no CSV found") { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Fatalf("expected empty path, got %q", got) + } + }) + + t.Run("finds CSV file", func(t *testing.T) { + dir := t.TempDir() + manifests := filepath.Join(dir, "manifests") + if err := os.MkdirAll(manifests, 0o755); err != nil { + t.Fatal(err) + } + csvFile := filepath.Join(manifests, "my-operator.clusterserviceversion.yaml") + if err := os.WriteFile(csvFile, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + got, err := findCSV(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != csvFile { + t.Fatalf("expected %q, got %q", csvFile, got) + } + }) +}