diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9854b342..3da6cf3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -300,9 +300,23 @@ jobs: olm: name: OLM Bundle & Catalog + needs: + - change-triage + - core + - operator + # the bundle needs the core and operator images + + if: | + !cancelled() && + (needs.change-triage.outputs.operator-changed == 'true' || + needs.change-triage.outputs.run-openshift == 'true') && + needs.operator.result == 'success' && + (needs.core.result == 'success' || needs.core.result == 'skipped') env: - # The OpenShift Catalog is just for internal testing purpose until it won't. ENVIRONMENT: "testing" + SKIP_IMAGE_DIGESTS: >- + ${{ (needs.change-triage.outputs.core-changed != 'true' && + needs.change-triage.outputs.run-openshift != 'true') && 'true' || '' }} runs-on: ubuntu-24.04 permissions: contents: read diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index a92fbbf0..62000a25 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -31,3 +31,133 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | task all:publish + + check-release: + name: Evaluate the release tag + needs: + - release-publish + if: github.repository_owner == 'cloudnative-pg' + runs-on: ubuntu-24.04 + permissions: + contents: read + outputs: + is-latest: ${{ steps.check.outputs.is-latest }} + steps: + - name: Check whether this is the newest release + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.event.release.tag_name }} + run: | + latest=$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" \ + --jq '.tag_name' 2>/dev/null || true) + is_latest="false" + if [ "${latest}" = "${TAG}" ]; then + is_latest="true" + fi + echo "is-latest=${is_latest}" >> "${GITHUB_OUTPUT}" + echo "Release ${TAG} is-latest=${is_latest} (latest is ${latest})" + + olm-bundle: + name: Build and push the OLM bundle and catalog + needs: + - release-publish + - check-release + if: needs.check-release.outputs.is-latest == 'true' + env: + ENVIRONMENT: "production" + runs-on: ubuntu-24.04 + permissions: + contents: write + packages: write + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + - name: Cleanup disk + uses: ./.github/actions/cleanup-disk + + - name: Setup dagger + uses: ./.github/actions/setup-dagger + + - name: Login to ghcr.io using Podman + uses: redhat-actions/podman-login@50c2d9a331bb67c8fdab99b86455fad05e2e3252 # v2 + with: + registry: "ghcr.io" + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push the bundle and catalog + run: | + task olm:publish + + - name: Upload the bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: bundle + path: operator/bundle + + - name: Attach the CatalogSource manifest to the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.event.release.tag_name }} + run: | + gh release upload "${TAG}" operator/klio-operator-catalog-source.yaml \ + --repo "${GITHUB_REPOSITORY}" --clobber + + publish-bundle: + name: Publish the OLM bundle to the artifacts repository + needs: + - olm-bundle + env: + VERSION: ${{ github.event.release.tag_name }} + BUNDLE_DIR: klio/bundles + concurrency: + group: publish-bundle + cancel-in-progress: false + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Checkout the artifacts repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + repository: cloudnative-pg/artifacts + token: ${{ secrets.REPO_GHA_PAT }} + ref: main + fetch-depth: 0 + + - name: Configure the git user + run: | + git config user.name "${GITHUB_ACTOR}" + git config user.email "${GITHUB_ACTOR_ID}+${GITHUB_ACTOR}@users.noreply.github.com" + + - name: Download the bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: bundle + path: downloaded-bundle + + - name: Copy the bundle + run: | + target="${BUNDLE_DIR}/${VERSION#v}" + rm -rf "${target}" + mkdir -p "${target}" + cp -R downloaded-bundle/* "${target}" + rm -rf downloaded-bundle + + - name: Commit and push the bundle + run: | + if [ -z "$(git status --porcelain)" ]; then + echo "Bundle for ${VERSION#v} is already up to date" + exit 0 + fi + git add "${BUNDLE_DIR}/${VERSION#v}" + git commit -sm "klio-operator (${VERSION#v})" + # The checkout above persisted REPO_GHA_PAT, so no separate push + # action is needed. Rebase first: the artifacts repository is shared + # with the other CloudNativePG projects and main may have moved. + git pull --rebase origin main + git push origin HEAD:main diff --git a/.gitignore b/.gitignore index 073b7fd0..2a9aa46f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ bundle/ operator/olm-manifests/ operator/config/manifest-build/ operator/config/olm-default/manager_sidecar_image_patch.yaml +operator/config/manifests/csv_container_image_patch.yaml +operator/config/manifests/csv_skip_range_patch.yaml +operator/config/manifests/sample_image_patch.yaml operator/catalog operator/catalog.Dockerfile operator/klio-operator-template.yaml diff --git a/Taskfile.yml b/Taskfile.yml index 941b2d9f..2e10f07f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -31,6 +31,24 @@ vars: # image, since that is the variant submitted to Red Hat certification. UBI_TAG_SUFFIX: '-ubi9' ALLOWED_ENVS: [testing, production] + # OLM channel the bundle is published on. "stable" is the support tier and + # "v0" pins the major version, per the OLM channel-naming convention + # (https://olm.operatorframework.io/docs/best-practices/channel-naming/). + # Bump to stable-v1 at the 1.0 release, by adding the new channel instead + # of renaming this one. + OLM_CHANNELS: 'stable-v0' + OLM_DEFAULT_CHANNEL: 'stable-v0' + # Minimum OpenShift version the bundle declares support for + OPENSHIFT_VERSIONS: 'v4.14' + # First release that ships an OLM bundle. It is the lower bound of the CSV's + # olm.skipRange, which is what lets a catalog carrying a single bundle still + # offer an upgrade from any earlier release (see olm:manifest). Never lower + # it, and only raise it if a version range is ever declared unupgradable. + OLM_FIRST_BUNDLE_VERSION: '0.0.21' + # Set to a non-empty value to build the OLM bundle without pinning images to + # digests. Defaults to empty, so digest pinning is on wherever it can work; + # see olm:bundle for when CI turns it off. + SKIP_IMAGE_DIGESTS: '{{ .SKIP_IMAGE_DIGESTS | default "" }}' tasks: @@ -754,6 +772,10 @@ tasks: # registry and tag, differing only by the "klio" repository instead of # "klio-operator". The patch is consumed by # operator/config/olm-default/kustomization.yaml. + # + # RELATED_IMAGE_SIDECAR carries the same value under the name OLM + # tooling recognizes: operator-sdk's --use-image-digests only discovers + # container images and RELATED_IMAGE_* env vars, - | cat > operator/config/olm-default/manager_sidecar_image_patch.yaml < operator/config/manifests/csv_container_image_patch.yaml < operator/config/manifests/csv_skip_range_patch.yaml <={{ .OLM_FIRST_BUNDLE_VERSION }} <{{ .GIT_TAG_VERSION }}" + EOF + - | + cat > operator/config/manifests/sample_image_patch.yaml < GITHUB_REF= dagger -s call -m github.com/sagikazarmark/daggerverse/kustomize@${DAGGER_KUSTOMIZE_SHA} @@ -780,9 +824,31 @@ tasks: olm:bundle: desc: Generate the OLM bundle for the operator + summary: | + Note that --use-image-digests makes operator-sdk resolve every image in + the CSV against its registry, so the operator image for this ref must + already be pushed when this runs. Callers must therefore order themselves + after the operator build (in CI, the olm job needs the operator job). + + Digest pinning is enabled only when running in GitHub Actions, where the + images live on ghcr.io. + + Resolution covers *every* image in the CSV, the operand included, so a + caller that cannot guarantee the operand image was pushed for this ref + must set SKIP_IMAGE_DIGESTS. The CI olm job does that on pull requests + where the core job is skipped; releases always build both, and never set + it. + run: once + requires: + vars: + - name: ENVIRONMENT + enum: + ref: .ALLOWED_ENVS deps: - olm:manifest + env: + use_image_digests: '{{ if and .GITHUB_ACTIONS (not .SKIP_IMAGE_DIGESTS) }}--use-image-digests{{ end }}' cmds: - > docker @@ -794,11 +860,20 @@ tasks: generate bundle --input-dir olm-manifests/ --kustomize-dir config/manifests + --package klio-operator + --channels {{ .OLM_CHANNELS }} + --default-channel {{ .OLM_DEFAULT_CHANNEL }} + ${use_image_digests} --version {{ .GIT_TAG_VERSION }} + - | + printf '\n # OpenShift annotations.\n com.redhat.openshift.versions: "%s"\n' \ + '{{ .OPENSHIFT_VERSIONS }}' >> operator/bundle/metadata/annotations.yaml + printf '\n# OpenShift labels.\nLABEL com.redhat.openshift.versions="%s"\n' \ + '{{ .OPENSHIFT_VERSIONS }}' >> operator/bundle.Dockerfile sources: - operator/olm-manifests/** generates: - - bundle/ + - operator/bundle/** olm:build: desc: Build the require OLM bundle image after generating the bundle @@ -941,6 +1016,37 @@ tasks: image: ${registry}/klio-operator${suffix}:${tag}-catalog EOF + olm:publish: + desc: Publish the OLM bundle and catalog images for a release + summary: | + Release counterpart of olm:all. Builds and pushes the production bundle + and catalog images (no "-testing" suffix) for the tag being released, and + leaves operator/bundle/ on disk for the release workflow to upload and + forward to the artifacts repository. + + Scorecard is deliberately not re-run here: it already ran against the + same bundle in the CI olm job, and it needs a k3s-capable engine that the + release runner is not set up for. + requires: + # We expect this to run in a GitHub workflow, so we put a few + # GitHub-specific vars here to prevent running this task locally by + # accident. + vars: + - CI + - GITHUB_REPOSITORY + - GITHUB_REF + - GITHUB_REF_NAME + preconditions: + - sh: "[[ {{.GITHUB_REF}} =~ 'refs/tags/v.*' ]]" + msg: not a tag, failing + - sh: '[[ "{{ .GIT_TAG_VERSION }}" == "{{ trimPrefix "v" .GITHUB_REF_NAME }}" ]]' + msg: >- + bundle version {{ .GIT_TAG_VERSION }} does not match tag + {{ .GITHUB_REF_NAME }}; refusing to publish an inconsistent bundle + cmds: + - task: olm:catalog + - task: olm:catalog-source + olm:preflight-container: desc: Run Red Hat Preflight (check container) certification on the operator image dir: operator diff --git a/documentation/.wordlist.txt b/documentation/.wordlist.txt index 838d92a0..b4de90af 100644 --- a/documentation/.wordlist.txt +++ b/documentation/.wordlist.txt @@ -15,6 +15,7 @@ CRC CRC's CRDs CSV +CSV's CatalogSource CheckpointTime ClientCASecretName @@ -290,6 +291,7 @@ pre prefetch prefetching preflight +prereleases priorityClassName prometheus proto @@ -319,6 +321,7 @@ uint ulong unicode unreferenced +unupgradable uptime verifications wal diff --git a/documentation/web/docs/developer/openshift_testing.md b/documentation/web/docs/developer/openshift_testing.md index 0b85ef27..6863c663 100644 --- a/documentation/web/docs/developer/openshift_testing.md +++ b/documentation/web/docs/developer/openshift_testing.md @@ -61,6 +61,12 @@ This procedure is for development and testing only. It requires a catalog image built from the branch under test. ::: +The bundle declares OpenShift 4.14 as the minimum supported version, +through the `com.redhat.openshift.versions` annotation. The value comes +from the `OPENSHIFT_VERSIONS` variable in `Taskfile.yml`, which +`olm:bundle` writes into both `bundle/metadata/annotations.yaml` and +the labels of `bundle.Dockerfile`. + ## Prerequisites - An OpenShift cluster with `oc` CLI configured @@ -137,10 +143,17 @@ the `openshift-operators` namespace. :::note The Klio sidecar (operand) image is baked into the operator -Deployment by the bundle as the `SIDECAR_IMAGE` environment -variable. It uses the same registry and tag as the operator -image by default, with the `klio` repository instead of -`klio-operator`. The Subscription can override it. +Deployment by the bundle, as both the `SIDECAR_IMAGE` and the +`RELATED_IMAGE_SIDECAR` environment variables; the operator prefers +the latter, and only the OLM bundle sets it. `RELATED_IMAGE_SIDECAR` +is the name `operator-sdk` expects, so that `--use-image-digests` +pins the operand to a digest and copies it into the CSV's +`relatedImages`, which is what disconnected installs mirror. Digest +pinning only happens in CI, where the images live on `ghcr.io`; a +local build leaves both variables on a tag. The images use the same +registry and tag as the operator image by default, with the `klio` +repository instead of `klio-operator`. The Subscription can override +either variable. ::: ## 3. Create TLS certificates @@ -277,3 +290,28 @@ in-engine. Raw artifacts are written to `operator/preflight-artifacts/`. The task never contacts Red Hat: the checks are always evaluated locally. + +## Publishing the bundle and catalog + +`olm:publish` is the release counterpart of `olm:all`: it builds and +pushes the production bundle and catalog images (no `-testing` suffix) +for the tag being released, and leaves `operator/bundle/` on disk. It +refuses to run outside a tagged CI checkout, and refuses to run if the +CSV version derived from `git describe` does not match the tag. + +The `olm-bundle` job in `.github/workflows/release-publish.yml` runs +it, attaches the resulting `CatalogSource` manifest to the GitHub +release, and hands `operator/bundle/` to the `publish-bundle` job, +which commits it to `klio/bundles/` in the +[artifacts repository](https://github.com/cloudnative-pg/artifacts). +Both jobs only run for the release GitHub marks as the latest one, +which excludes drafts and prereleases, so a release candidate never +reaches the `stable-v0` channel. + +Each release publishes its own catalog image, tagged with the release +version, and that catalog carries exactly one bundle. The upgrade edge +back to the installed version comes from the `olm.skipRange` annotation +`olm:manifest` writes into the CSV, which spans every release from +`OLM_FIRST_BUNDLE_VERSION` up to (but excluding) the one being built. +Raise that variable only to declare a version range unupgradable, and +never lower it. diff --git a/operator/config/manifests/bases/klio-operator.clusterserviceversion.yaml b/operator/config/manifests/bases/klio-operator.clusterserviceversion.yaml index cec60823..fdc95bd6 100644 --- a/operator/config/manifests/bases/klio-operator.clusterserviceversion.yaml +++ b/operator/config/manifests/bases/klio-operator.clusterserviceversion.yaml @@ -395,6 +395,24 @@ spec: TLS certificates for the Klio server and its clients. * Persistent Volume Claims that support `ReadWriteOnce`. + ## Before creating a Server + + A `Server` names the TLS and encryption material it needs, and the + operator does not create it: the Secrets must already exist in the + `Server`'s namespace. The example `Server` below expects three that + are **not** part of this bundle: + + * `server-sample-tls`, the certificate the Klio server presents, and + `server-sample-ca`, the CA it validates clients against. Both are + normally issued by cert-manager `Certificate` resources. + * `server-sample-encryption`, holding an `encryption-key` entry (the + Age-encrypted encryption key) and a `secret-key` entry (the Age + identity that decrypts it). + + Those names are only the example's defaults: any names work, as long + as `tlsSecretName`, `caSecretName`, and the + `encryptionKeyFile`/`identityFile` references agree with them. + ## Getting started Full installation and configuration instructions are available in diff --git a/operator/config/manifests/kustomization.yaml b/operator/config/manifests/kustomization.yaml index adcc04c5..fc7c90b3 100644 --- a/operator/config/manifests/kustomization.yaml +++ b/operator/config/manifests/kustomization.yaml @@ -6,6 +6,31 @@ resources: - ../samples - ../scorecard +# Point the CSV's containerImage annotation at the operator image actually +# being built. The patch is generated (with the right image) by the +# `olm:manifest` task and is git ignored. +patches: +- path: csv_container_image_patch.yaml + target: + kind: ClusterServiceVersion +# Declare which already-released versions this bundle can be installed over, +# as olm.skipRange. Also generated by `olm:manifest` and git ignored; see the +# task for why a single-bundle catalog needs it. +- path: csv_skip_range_patch.yaml + target: + kind: ClusterServiceVersion +# Point the sample Server at the operand image actually being built, rather +# than the local dev registry the committed sample uses. Also generated by +# `olm:manifest` and git ignored. +- path: sample_image_patch.yaml + target: + group: klio.cnpg.io + version: v1alpha1 + kind: Server +# Keep the sample's example encryption Secret out of the bundle; see the patch +# for why. +- path: remove_sample_secret.yaml + # [WEBHOOK] To enable webhooks, uncomment all the sections with [WEBHOOK] prefix. # Do NOT uncomment sections with prefix [CERTMANAGER], as OLM does not support cert-manager. # These patches remove the unnecessary "cert" volume and its manager container volumeMount. diff --git a/operator/config/manifests/remove_sample_secret.yaml b/operator/config/manifests/remove_sample_secret.yaml new file mode 100644 index 00000000..12f414ce --- /dev/null +++ b/operator/config/manifests/remove_sample_secret.yaml @@ -0,0 +1,10 @@ +# The sample Server ships alongside an example encryption Secret, so that +# applying config/samples/klio_v1alpha1_server.yaml works in a single step +# locally. Secret is an OLM-supported bundle kind, so operator-sdk copies it +# into bundle/manifests/ and OLM would create it on every install of the +# operator: we do not need the example in the bundle +apiVersion: v1 +kind: Secret +metadata: + name: server-sample-encryption +$patch: delete diff --git a/operator/internal/cnpgi/lifecycle.go b/operator/internal/cnpgi/lifecycle.go index 2215895b..2b90b2a8 100644 --- a/operator/internal/cnpgi/lifecycle.go +++ b/operator/internal/cnpgi/lifecycle.go @@ -439,7 +439,7 @@ func reconcilePodSpec( // NOSONAR } sidecarTemplate := corev1.Container{ - Image: os.Getenv("SIDECAR_IMAGE"), + Image: sidecarImage(), RestartPolicy: new(corev1.ContainerRestartPolicyAlways), ImagePullPolicy: cluster.Spec.ImagePullPolicy, SecurityContext: sidecarSecurityContext(cfg.haveSCC), @@ -536,6 +536,22 @@ func reconcilePodSpec( // NOSONAR return nil } +// sidecarImage returns the Klio plugin sidecar (operand) image the operator +// injects into PostgreSQL pods. +// +// RELATED_IMAGE_SIDECAR takes precedence over SIDECAR_IMAGE because it is the +// name OLM tooling recognizes: operator-sdk pins RELATED_IMAGE_* env vars to +// image digests and copies them into the CSV's relatedImages, which is what +// disconnected installs mirror. Only the OLM bundle sets it; the Helm chart and +// every other deployment path keep using SIDECAR_IMAGE. +func sidecarImage() string { + if image := os.Getenv("RELATED_IMAGE_SIDECAR"); image != "" { + return image + } + + return os.Getenv("SIDECAR_IMAGE") +} + // sidecarSecurityContext returns the SecurityContext for the Klio plugin // sidecar container. On OpenShift it returns nil so that the restricted SCC // can assign a namespace-allocated UID. diff --git a/operator/internal/cnpgi/lifecycle_test.go b/operator/internal/cnpgi/lifecycle_test.go index 9b5edceb..b25a0092 100644 --- a/operator/internal/cnpgi/lifecycle_test.go +++ b/operator/internal/cnpgi/lifecycle_test.go @@ -184,6 +184,46 @@ func TestFindUserContainer(t *testing.T) { } } +func TestSidecarImage(t *testing.T) { + tests := []struct { + name string + relatedImage string + sidecarImage string + expectedImage string + }{ + { + name: "only SIDECAR_IMAGE set", + sidecarImage: "ghcr.io/cloudnative-pg/klio:v1", + expectedImage: "ghcr.io/cloudnative-pg/klio:v1", + }, + { + name: "RELATED_IMAGE_SIDECAR wins when both are set", + relatedImage: "ghcr.io/cloudnative-pg/klio@sha256:abc", + sidecarImage: "ghcr.io/cloudnative-pg/klio:v1", + expectedImage: "ghcr.io/cloudnative-pg/klio@sha256:abc", + }, + { + name: "empty RELATED_IMAGE_SIDECAR falls back", + relatedImage: "", + sidecarImage: "ghcr.io/cloudnative-pg/klio:v1", + expectedImage: "ghcr.io/cloudnative-pg/klio:v1", + }, + { + name: "neither set", + expectedImage: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("RELATED_IMAGE_SIDECAR", tt.relatedImage) + t.Setenv("SIDECAR_IMAGE", tt.sidecarImage) + + assert.Equal(t, tt.expectedImage, sidecarImage()) + }) + } +} + func TestEnsureEnvVar(t *testing.T) { tests := []struct { name string