diff --git a/.github/workflows/release_documentdb_images.yml b/.github/workflows/release_documentdb_images.yml index fd8136d21..0e83843d4 100644 --- a/.github/workflows/release_documentdb_images.yml +++ b/.github/workflows/release_documentdb_images.yml @@ -74,8 +74,8 @@ jobs: - name: Detect current default version id: current run: | - # Extract the assigned DEFAULT_DOCUMENTDB_IMAGE value from constants.go. - CURRENT=$(sed -nE 's|^[[:space:]]*DEFAULT_DOCUMENTDB_IMAGE[[:space:]]*=.*:([0-9]+\.[0-9]+\.[0-9]+)".*|\1|p' \ + # Extract the assigned DEFAULT_DOCUMENTDB_TAG value from constants.go. + CURRENT=$(sed -nE 's|^[[:space:]]*DEFAULT_DOCUMENTDB_TAG[[:space:]]*=[[:space:]]*"([0-9]+\.[0-9]+\.[0-9]+)".*|\1|p' \ operator/src/internal/utils/constants.go | head -1) if [[ -z "$CURRENT" ]]; then echo "Failed to extract current default version from operator/src/internal/utils/constants.go" >&2 @@ -98,8 +98,10 @@ jobs: echo "Updating default versions: $OLD_VERSION → $NEW_VERSION" - # 1. Update operator constants.go - sed -i "s|:${OLD_VERSION}\"|:${NEW_VERSION}\"|g" \ + # 1. Update operator constants.go. DEFAULT_DOCUMENTDB_TAG is the single + # version literal; the DEFAULT_DOCUMENTDB_IMAGE/DEFAULT_GATEWAY_IMAGE + # defaults are composed from it, so bumping the tag is sufficient. + sed -i "s|DEFAULT_DOCUMENTDB_TAG = \"${OLD_VERSION}\"|DEFAULT_DOCUMENTDB_TAG = \"${NEW_VERSION}\"|" \ operator/src/internal/utils/constants.go # 2. Update sidecar plugin config.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 695e6c1a1..6904cd9c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,13 @@ ## [Unreleased] ### Major Features +- **Configurable image registry and repositories**: The Helm chart now exposes an `image.registry` prefix (default `ghcr.io`) plus relative per-component `repository` values, so every operator and data-plane image (operator, sidecar-injector, wal-replica, documentdb extension, gateway, and the optional OpenTelemetry collector / PostgreSQL operand) can be re-homed to a private or mirrored registry without rebuilding the operator. A `repository` that already includes a host (contains `.` or `:`, or is `localhost`) is used verbatim and ignores the prefix, letting you point a single image at a different registry. Repositories are host/path only — the tag is supplied via each component's `tag` field (or `documentDbVersion` for the data-plane images), and a tag or digest embedded in a `repository` is rejected at render time. For the optional OpenTelemetry collector and PostgreSQL operand images (which have no fallback tag source), the `repository` and `tag` must be set together — supplying only one is rejected at render time so a mistyped override can never leak a floating `:latest` or be silently dropped. Default rendering is unchanged (`ghcr.io/documentdb/...`), so existing deployments are unaffected. - **Fail-fast ImageVolume capability check**: The operator now depends on the Kubernetes [ImageVolume](https://kubernetes.io/docs/concepts/storage/volumes/#image) feature to mount the DocumentDB extension into PostgreSQL pods. Instead of gating on a Kubernetes version number, the validating webhook performs a capability probe (a server-side dry-run) when a `DocumentDB` is created and **rejects the resource with an actionable error if ImageVolume is unavailable**, so you find out immediately instead of waiting for pods that never become ready. ImageVolume is GA (on by default) in Kubernetes **1.35+**; on **1.33/1.34** it is beta and must be enabled via the `ImageVolume` feature gate on a containerd/CRI-O runtime. The Helm chart's `kubeVersion` floor is relaxed to `>= 1.33.0-0` accordingly. See [Before you start](docs/operator-public-documentation/preview/getting-started/before-you-start.md). - **DocumentDB extension packages from PGDG**: The database image build now resolves `postgresql-18-documentdb` from the [PGDG APT repository](https://apt.postgresql.org/) (`trixie-pgdg`), pinning one version and SHA256 across both architectures and validating `default_version` before building. Replaces the upstream Debian 13 release assets that stopped being published after `0.115`. +### Breaking Changes +- **Image pull-policy Helm values moved under `image`**: The top-level `gatewayImagePullPolicy` and `documentDbImagePullPolicy` chart values have been removed and replaced by `image.gateway.pullPolicy` and `image.documentdb.pullPolicy` (both default `IfNotPresent`), aligning them with the other per-component image settings. Existing values files or `--set` overrides using the old keys no longer take effect; update them to the new nested keys. (Preview-only breaking change.) + ## [0.3.0] - 2026-07-15 ### Security diff --git a/docs/designs/image-management.md b/docs/designs/image-management.md index 93faad6cd..c66ffbf00 100644 --- a/docs/designs/image-management.md +++ b/docs/designs/image-management.md @@ -164,10 +164,16 @@ image: ### Image Tag Resolution in Templates -**Operator-track images** use `Chart.AppVersion`: +**Operator-track images** are composed by the `documentdb.imageRef` helper, which +prepends `image.registry` to a relative repository (or uses the repository +verbatim when it already carries a host, i.e. its first segment contains `.` or +`:`, or equals `localhost`) and appends the tag, defaulting to `Chart.AppVersion`: ```yaml -image: "{{ .Values.image.documentdbk8soperator.repository }}:{{ .Values.image.documentdbk8soperator.tag | default .Chart.AppVersion }}" +image: "{{ include "documentdb.imageRef" (dict "name" "image.documentdbk8soperator.repository" "registry" .Values.image.registry "repo" .Values.image.documentdbk8soperator.repository "tag" (.Values.image.documentdbk8soperator.tag | default .Chart.AppVersion)) }}" ``` +Repositories are host/path only: an empty registry/repository, or a tag or digest +embedded in the repository, fails rendering (naming the offending setting) rather +than producing an invalid reference. **Database version** is passed as an environment variable: ```yaml diff --git a/operator/documentdb-helm-chart/templates/02_documentdb_sidecar_injector.yaml b/operator/documentdb-helm-chart/templates/02_documentdb_sidecar_injector.yaml index 1dafcded7..4abb66ffd 100644 --- a/operator/documentdb-helm-chart/templates/02_documentdb_sidecar_injector.yaml +++ b/operator/documentdb-helm-chart/templates/02_documentdb_sidecar_injector.yaml @@ -70,7 +70,7 @@ spec: - --server-key=/server/tls.key - --client-cert=/client/tls.crt - --server-address=:9090 - image: "{{ .Values.image.sidecarinjector.repository }}:{{ .Values.image.sidecarinjector.tag | default .Chart.AppVersion }}" + image: "{{ include "documentdb.imageRef" (dict "name" "image.sidecarinjector.repository" "registry" .Values.image.registry "repo" .Values.image.sidecarinjector.repository "tag" (.Values.image.sidecarinjector.tag | default .Chart.AppVersion)) }}" imagePullPolicy: "{{ .Values.image.sidecarinjector.pullPolicy }}" name: cnpg-i-sidecar-injector {{- with .Values.sidecarInjector.containerSecurityContext }} diff --git a/operator/documentdb-helm-chart/templates/03_documentdb_wal_replica.yaml b/operator/documentdb-helm-chart/templates/03_documentdb_wal_replica.yaml index f16933457..326638a7e 100644 --- a/operator/documentdb-helm-chart/templates/03_documentdb_wal_replica.yaml +++ b/operator/documentdb-helm-chart/templates/03_documentdb_wal_replica.yaml @@ -115,7 +115,7 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} containers: - - image: "{{ .Values.image.walreplica.repository }}:{{ .Values.image.walreplica.tag | default .Chart.AppVersion }}" + - image: "{{ include "documentdb.imageRef" (dict "name" "image.walreplica.repository" "registry" .Values.image.registry "repo" .Values.image.walreplica.repository "tag" (.Values.image.walreplica.tag | default .Chart.AppVersion)) }}" imagePullPolicy: "{{ .Values.image.walreplica.pullPolicy }}" name: cnpg-i-wal-replica {{- with .Values.walReplicaPlugin.containerSecurityContext }} diff --git a/operator/documentdb-helm-chart/templates/09_documentdb_operator.yaml b/operator/documentdb-helm-chart/templates/09_documentdb_operator.yaml index e99b393cf..e8b1bf677 100644 --- a/operator/documentdb-helm-chart/templates/09_documentdb_operator.yaml +++ b/operator/documentdb-helm-chart/templates/09_documentdb_operator.yaml @@ -47,7 +47,7 @@ spec: {{- end }} containers: - name: documentdb-operator - image: "{{ .Values.image.documentdbk8soperator.repository }}:{{ .Values.image.documentdbk8soperator.tag | default .Chart.AppVersion }}" + image: "{{ include "documentdb.imageRef" (dict "name" "image.documentdbk8soperator.repository" "registry" .Values.image.registry "repo" .Values.image.documentdbk8soperator.repository "tag" (.Values.image.documentdbk8soperator.tag | default .Chart.AppVersion)) }}" imagePullPolicy: "{{ .Values.image.documentdbk8soperator.pullPolicy }}" {{- with .Values.operator.containerSecurityContext }} securityContext: @@ -115,13 +115,39 @@ spec: - name: DOCUMENTDB_VERSION value: "{{ .Values.documentDbVersion }}" {{- end }} - {{- if .Values.gatewayImagePullPolicy }} + # Data-plane image repositories (host+path). The operator composes the + # final extension/gateway image using DOCUMENTDB_VERSION as the tag. + {{- if .Values.image.documentdb.repository }} + - name: DOCUMENTDB_EXTENSION_IMAGE_REPO + value: "{{ include "documentdb.imageRef" (dict "name" "image.documentdb.repository" "registry" .Values.image.registry "repo" .Values.image.documentdb.repository "tag" "") }}" + {{- end }} + {{- if .Values.image.gateway.repository }} + - name: GATEWAY_IMAGE_REPO + value: "{{ include "documentdb.imageRef" (dict "name" "image.gateway.repository" "registry" .Values.image.registry "repo" .Values.image.gateway.repository "tag" "") }}" + {{- end }} + # Extra images: composed via the same registry-prefix rule. The + # repository and tag must be set together (both or neither); otherwise + # the operator uses its compiled-in default (otel) or defers to CNPG + # (postgres). + {{- $otelImage := include "documentdb.extraImageRef" (dict "registry" .Values.image.registry "repo" .Values.image.otelCollector.repository "tag" .Values.image.otelCollector.tag "repoName" "image.otelCollector.repository" "tagName" "image.otelCollector.tag") }} + {{- if $otelImage }} + - name: OTEL_COLLECTOR_IMAGE + value: "{{ $otelImage }}" + {{- end }} + {{- $postgresImage := include "documentdb.extraImageRef" (dict "registry" .Values.image.registry "repo" .Values.image.postgres.repository "tag" .Values.image.postgres.tag "repoName" "image.postgres.repository" "tagName" "image.postgres.tag") }} + {{- if $postgresImage }} + - name: POSTGRES_IMAGE + value: "{{ $postgresImage }}" + {{- end }} + {{- $gwPolicy := include "documentdb.pullPolicy" (dict "name" "image.gateway.pullPolicy" "policy" .Values.image.gateway.pullPolicy) }} + {{- if $gwPolicy }} - name: GATEWAY_IMAGE_PULL_POLICY - value: "{{ .Values.gatewayImagePullPolicy }}" + value: "{{ $gwPolicy }}" {{- end }} - {{- if .Values.documentDbImagePullPolicy }} + {{- $ddbPolicy := include "documentdb.pullPolicy" (dict "name" "image.documentdb.pullPolicy" "policy" .Values.image.documentdb.pullPolicy) }} + {{- if $ddbPolicy }} - name: DOCUMENTDB_IMAGE_PULL_POLICY - value: "{{ .Values.documentDbImagePullPolicy }}" + value: "{{ $ddbPolicy }}" {{- end }} {{- if .Values.operator.ioUring.seccompProfile }} - name: DOCUMENTDB_IOURING_SECCOMP_PROFILE diff --git a/operator/documentdb-helm-chart/templates/_helpers.tpl b/operator/documentdb-helm-chart/templates/_helpers.tpl index 1399543d9..f09353277 100644 --- a/operator/documentdb-helm-chart/templates/_helpers.tpl +++ b/operator/documentdb-helm-chart/templates/_helpers.tpl @@ -1,3 +1,94 @@ {{- define "documentdb-chart.name" -}} documentdb-operator +{{- end -}} + +{{/* +documentdb.imageRef composes a container image reference from a registry prefix, +a repository, and an optional tag, following the canonical Docker/containerd +reference rule for deciding whether the repository already carries a registry +host: the first path segment is treated as a registry host when it contains a +"." or ":" (port), or equals "localhost". In that case the repository is used +verbatim and the registry prefix is ignored (this is also the per-component +per-registry override path). Otherwise the registry prefix is prepended. + +The repository must be a non-empty host/path only. Rendering fails (naming the +offending setting via "name") when the repository is empty, when it already +carries a tag or digest (a ":" or "@" in the final path segment), or when the +registry prefix would be needed but is empty. The tag is always supplied via the +component's tag field (or documentDbVersion for the data-plane images) rather +than embedded in the repository. + +Usage: + {{ include "documentdb.imageRef" (dict "name" "image.foo.repository" "registry" .Values.image.registry "repo" $repo "tag" $tag) }} +Pass tag "" to compose a bare repository (host+path, no tag). +*/}} +{{- define "documentdb.imageRef" -}} +{{- $name := .name | default "image repository" -}} +{{- if not .repo -}} +{{- fail (printf "%s is empty; set it to a host/path (with image.registry) or a full host reference" $name) -}} +{{- end -}} +{{- $lastSeg := (splitList "/" .repo) | last -}} +{{- if or (contains ":" $lastSeg) (contains "@" $lastSeg) -}} +{{- fail (printf "%s value %q must be a host/path without a tag or digest; set the tag via the component's tag field (or documentDbVersion for data-plane images)" $name .repo) -}} +{{- end -}} +{{- $first := (splitList "/" .repo) | first -}} +{{- $full := .repo -}} +{{- if not (or (contains "." $first) (contains ":" $first) (eq $first "localhost")) -}} +{{- if not .registry -}} +{{- fail (printf "image.registry is empty but %s (%q) is a relative path; set image.registry or use a full host reference in the repository" $name .repo) -}} +{{- end -}} +{{- $full = printf "%s/%s" .registry .repo -}} +{{- end -}} +{{- if .tag -}} +{{- printf "%s:%s" $full .tag -}} +{{- else -}} +{{- $full -}} +{{- end -}} +{{- end -}} + +{{/* +documentdb.extraImageRef composes an optional override image reference for the +"extra" images (otel collector, postgres) that carry no fallback tag source. +Unlike the first-party images (whose tag defaults to Chart.AppVersion or +documentDbVersion), these have no default tag, so a partial override is always a +mistake: a repository without a tag would leak a floating ":latest", and a tag +without a repository would be silently dropped. This helper therefore enforces +that the component's repository and tag are set together (both or neither): + + - both empty -> returns "" (caller omits the env var; the operator uses its + compiled-in default for otel, or defers to CNPG for postgres) + - both set -> returns the composed "registry/repo:tag" reference + - exactly one -> rendering fails, naming both settings + +Usage: + {{ include "documentdb.extraImageRef" (dict "registry" .Values.image.registry "repo" $repo "tag" $tag "repoName" "image.otelCollector.repository" "tagName" "image.otelCollector.tag") }} +*/}} +{{- define "documentdb.extraImageRef" -}} +{{- if and .repo (not .tag) -}} +{{- fail (printf "%s is set but %s is empty; pin a tag (%s and %s must be set together)" .repoName .tagName .repoName .tagName) -}} +{{- end -}} +{{- if and .tag (not .repo) -}} +{{- fail (printf "%s is set but %s is empty; set the repository (%s and %s must be set together)" .tagName .repoName .repoName .tagName) -}} +{{- end -}} +{{- if .repo -}} +{{- include "documentdb.imageRef" (dict "name" .repoName "registry" .registry "repo" .repo "tag" .tag) -}} +{{- end -}} +{{- end -}} + +{{/* +documentdb.pullPolicy validates and echoes an image pull policy. An empty value +yields an empty string (the caller omits the setting and the consumer applies +its own default); a non-empty value must be one of Always, IfNotPresent, or +Never, otherwise rendering fails naming the offending setting via "name". + +Usage: + {{ include "documentdb.pullPolicy" (dict "name" "image.gateway.pullPolicy" "policy" .Values.image.gateway.pullPolicy) }} +*/}} +{{- define "documentdb.pullPolicy" -}} +{{- if .policy -}} +{{- if not (has .policy (list "Always" "IfNotPresent" "Never")) -}} +{{- fail (printf "%s must be one of Always, IfNotPresent, Never (got %q)" .name .policy) -}} +{{- end -}} +{{- .policy -}} +{{- end -}} {{- end -}} \ No newline at end of file diff --git a/operator/documentdb-helm-chart/tests/09_operator_deployment_test.yaml b/operator/documentdb-helm-chart/tests/09_operator_deployment_test.yaml index 53d30a7bd..d7ca467cb 100644 --- a/operator/documentdb-helm-chart/tests/09_operator_deployment_test.yaml +++ b/operator/documentdb-helm-chart/tests/09_operator_deployment_test.yaml @@ -62,6 +62,189 @@ tests: path: spec.template.spec.containers[0].imagePullPolicy value: "IfNotPresent" + # ------------------------------------------------------------------- + # Registry prefix (single-knob image bundle) + # ------------------------------------------------------------------- + - it: should prefix the operator image with image.registry + set: + image.registry: mcr.microsoft.com + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: "mcr.microsoft.com/documentdb/documentdb-kubernetes-operator/operator:0.3.0" + + - it: should use a per-component full-ref repository verbatim, ignoring image.registry + set: + image.registry: mcr.microsoft.com + image.documentdbk8soperator.repository: myacr.azurecr.io/cp/operator + image.documentdbk8soperator.tag: v9 + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: "myacr.azurecr.io/cp/operator:v9" + + - it: should treat a localhost:port repository as a full ref, ignoring image.registry + set: + image.registry: mcr.microsoft.com + image.documentdbk8soperator.repository: localhost:5000/cp/operator + image.documentdbk8soperator.tag: dev + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: "localhost:5000/cp/operator:dev" + + - it: should treat a localhost repository as a full ref, ignoring image.registry + set: + image.registry: mcr.microsoft.com + image.documentdbk8soperator.repository: localhost/cp/operator + image.documentdbk8soperator.tag: dev + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: "localhost/cp/operator:dev" + + - it: should reject a repository that embeds a tag + set: + image.documentdbk8soperator.repository: myacr.io/cp/operator:custom + asserts: + - failedTemplate: + errorPattern: "must be a host/path without a tag or digest" + + - it: should reject a repository that embeds a digest + set: + image.documentdb.repository: myacr.io/documentdb@sha256:abc123 + asserts: + - failedTemplate: + errorPattern: "must be a host/path without a tag or digest" + + - it: should fail when image.registry is empty for a relative repository + set: + image.registry: "" + asserts: + - failedTemplate: + errorPattern: "image.registry is empty" + + - it: should fail when a component repository is empty, naming the setting + set: + image.documentdbk8soperator.repository: "" + asserts: + - failedTemplate: + errorPattern: "image.documentdbk8soperator.repository is empty" + + - it: should reject an invalid gateway pull policy + set: + image.gateway.pullPolicy: Sometimes + asserts: + - failedTemplate: + errorPattern: "image.gateway.pullPolicy must be one of Always, IfNotPresent, Never" + + - it: should reject an invalid documentdb pull policy + set: + image.documentdb.pullPolicy: Sometimes + asserts: + - failedTemplate: + errorPattern: "image.documentdb.pullPolicy must be one of Always, IfNotPresent, Never" + + - it: should accept a valid non-default gateway pull policy + set: + image.gateway.pullPolicy: Always + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: GATEWAY_IMAGE_PULL_POLICY + value: "Always" + + # ------------------------------------------------------------------- + # Data-plane image repository env + # ------------------------------------------------------------------- + - it: should set data-plane repo env using the default registry + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: DOCUMENTDB_EXTENSION_IMAGE_REPO + value: "ghcr.io/documentdb/documentdb-kubernetes-operator/documentdb" + - contains: + path: spec.template.spec.containers[0].env + content: + name: GATEWAY_IMAGE_REPO + value: "ghcr.io/documentdb/documentdb-kubernetes-operator/gateway" + + - it: should re-home data-plane repo env via image.registry + set: + image.registry: mcr.microsoft.com + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: DOCUMENTDB_EXTENSION_IMAGE_REPO + value: "mcr.microsoft.com/documentdb/documentdb-kubernetes-operator/documentdb" + + - it: should omit otel/postgres env by default + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: OTEL_COLLECTOR_IMAGE + any: true + - notContains: + path: spec.template.spec.containers[0].env + content: + name: POSTGRES_IMAGE + any: true + + - it: should set otel/postgres env as full mirrored refs when overridden + set: + image.registry: mcr.microsoft.com + image.otelCollector.repository: oss/otel/opentelemetry-collector-contrib + image.otelCollector.tag: "0.149.0" + image.postgres.repository: oss/cloudnative-pg/postgresql + image.postgres.tag: "17.6" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: OTEL_COLLECTOR_IMAGE + value: "mcr.microsoft.com/oss/otel/opentelemetry-collector-contrib:0.149.0" + - contains: + path: spec.template.spec.containers[0].env + content: + name: POSTGRES_IMAGE + value: "mcr.microsoft.com/oss/cloudnative-pg/postgresql:17.6" + + - it: should fail when otelCollector.repository is set without a tag + set: + image.otelCollector.repository: oss/otel/opentelemetry-collector-contrib + image.otelCollector.tag: "" + asserts: + - failedTemplate: + errorMessage: "image.otelCollector.repository is set but image.otelCollector.tag is empty; pin a tag (image.otelCollector.repository and image.otelCollector.tag must be set together)" + + - it: should fail when otelCollector.tag is set without a repository + set: + image.otelCollector.repository: "" + image.otelCollector.tag: "0.149.0" + asserts: + - failedTemplate: + errorMessage: "image.otelCollector.tag is set but image.otelCollector.repository is empty; set the repository (image.otelCollector.repository and image.otelCollector.tag must be set together)" + + - it: should fail when postgres.repository is set without a tag + set: + image.postgres.repository: oss/cloudnative-pg/postgresql + image.postgres.tag: "" + asserts: + - failedTemplate: + errorMessage: "image.postgres.repository is set but image.postgres.tag is empty; pin a tag (image.postgres.repository and image.postgres.tag must be set together)" + + - it: should fail when postgres.tag is set without a repository + set: + image.postgres.repository: "" + image.postgres.tag: "17.6" + asserts: + - failedTemplate: + errorMessage: "image.postgres.tag is set but image.postgres.repository is empty; set the repository (image.postgres.repository and image.postgres.tag must be set together)" + # ------------------------------------------------------------------- # Environment variables # ------------------------------------------------------------------- @@ -85,7 +268,7 @@ tests: - it: should set GATEWAY_IMAGE_PULL_POLICY when configured set: - gatewayImagePullPolicy: "Never" + image.gateway.pullPolicy: "Never" asserts: - contains: path: spec.template.spec.containers[0].env @@ -94,6 +277,8 @@ tests: value: "Never" - it: should omit GATEWAY_IMAGE_PULL_POLICY when empty + set: + image.gateway.pullPolicy: "" asserts: - notContains: path: spec.template.spec.containers[0].env @@ -101,9 +286,17 @@ tests: name: GATEWAY_IMAGE_PULL_POLICY any: true + - it: should set GATEWAY_IMAGE_PULL_POLICY to IfNotPresent by default + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: GATEWAY_IMAGE_PULL_POLICY + value: "IfNotPresent" + - it: should set DOCUMENTDB_IMAGE_PULL_POLICY when configured set: - documentDbImagePullPolicy: "IfNotPresent" + image.documentdb.pullPolicy: "IfNotPresent" asserts: - contains: path: spec.template.spec.containers[0].env @@ -112,6 +305,8 @@ tests: value: "IfNotPresent" - it: should omit DOCUMENTDB_IMAGE_PULL_POLICY when empty + set: + image.documentdb.pullPolicy: "" asserts: - notContains: path: spec.template.spec.containers[0].env @@ -119,6 +314,14 @@ tests: name: DOCUMENTDB_IMAGE_PULL_POLICY any: true + - it: should set DOCUMENTDB_IMAGE_PULL_POLICY to IfNotPresent by default + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: DOCUMENTDB_IMAGE_PULL_POLICY + value: "IfNotPresent" + - it: should always set GATEWAY_PORT asserts: - contains: diff --git a/operator/documentdb-helm-chart/values.yaml b/operator/documentdb-helm-chart/values.yaml index b88a0fadb..8b43cbf13 100644 --- a/operator/documentdb-helm-chart/values.yaml +++ b/operator/documentdb-helm-chart/values.yaml @@ -6,17 +6,7 @@ replicaCount: 1 # which determines the default documentdb extension and gateway image tags at runtime. # This version is INDEPENDENT of Chart.appVersion (which controls operator/sidecar image tags). # When empty, the operator falls back to its compiled-in defaults (see constants.go). -documentDbVersion: "0.117.0" - -# Gateway image pull policy for the gateway sidecar container. -# Valid values: Always, IfNotPresent, Never. Defaults to IfNotPresent if not set. -gatewayImagePullPolicy: "" - -# DocumentDB extension image pull policy for the ImageVolume. -# Valid values: Always, IfNotPresent, Never. If not set, Kubernetes default behavior is used. -# This sets ImageVolumeSource.PullPolicy on the CNPG extension configuration -# (see operator/src/internal/cnpg/cnpg_cluster.go). -documentDbImagePullPolicy: "" +documentDbVersion: "0.117.0" serviceAccount: create: true @@ -45,17 +35,53 @@ walReplica: false # Set to true to deploy the WAL replica plugin imagePullSecrets: [] image: + # Default registry prefix. Applied to any `repository` below that is a bare + # path (no host). Set this once to move all images to another registry, e.g. + # a private or mirrored registry. Default is the public GHCR registry. + # A `repository` that already includes a host (has "." or ":", or is + # "localhost") is used as-is and ignores this prefix — that's how you point a + # single image at a different registry. + # Every `repository` is a host/path only: put the tag in the component's + # `tag` field (or `documentDbVersion` for the data-plane images). A tag or + # digest embedded in a `repository` is rejected at render time. + registry: ghcr.io + + # --- Operator (control-plane) images. tag defaults to Chart.AppVersion. --- documentdbk8soperator: - repository: ghcr.io/documentdb/documentdb-kubernetes-operator/operator - # Pinned image tags use IfNotPresent to avoid unnecessary registry pulls on pod restart. + repository: documentdb/documentdb-kubernetes-operator/operator + # Empty = Chart.AppVersion. The release/test chart-build pins this per + # platform; keep the key present so that rewrite targets this block. + tag: "" pullPolicy: IfNotPresent sidecarinjector: - repository: ghcr.io/documentdb/documentdb-kubernetes-operator/sidecar + repository: documentdb/documentdb-kubernetes-operator/sidecar + tag: "" pullPolicy: IfNotPresent walreplica: - repository: ghcr.io/documentdb/documentdb-kubernetes-operator/wal-replica + repository: documentdb/documentdb-kubernetes-operator/wal-replica + tag: "" pullPolicy: IfNotPresent + # --- Data-plane images. tag defaults to documentDbVersion (above). --- + documentdb: + repository: documentdb/documentdb-kubernetes-operator/documentdb + pullPolicy: IfNotPresent + gateway: + repository: documentdb/documentdb-kubernetes-operator/gateway + pullPolicy: IfNotPresent + + # --- Extra images. Default to a built-in image; set a repository to override. + # Same rule as above: a bare path gets the `registry` prefix, a full ref + # (with a host) is used as-is. --- + otelCollector: + # Empty = operator's built-in default. + repository: "" + tag: "" + postgres: + # Empty = operator's built-in default. + repository: "" + tag: "" + # --------------------------------------------------------------------------- # Preflight checks # --------------------------------------------------------------------------- diff --git a/operator/src/internal/cnpg/cnpg_cluster.go b/operator/src/internal/cnpg/cnpg_cluster.go index 8b9bc8e14..b97597901 100644 --- a/operator/src/internal/cnpg/cnpg_cluster.go +++ b/operator/src/internal/cnpg/cnpg_cluster.go @@ -109,7 +109,7 @@ func GetCnpgClusterSpecFromIntent(req ctrl.Request, documentdb *dbpreview.Docume // Sidecar is only injected when monitoring is enabled. // Config hash triggers operator-initiated rolling restart on config changes. if split.MonitoringEnabled { - params["otelCollectorImage"] = util.DEFAULT_OTEL_COLLECTOR_IMAGE + params["otelCollectorImage"] = util.OtelCollectorImage() params["otelConfigMapName"] = otelcfg.ConfigMapName(documentdb.Name) addPluginParamIfSet(params, util.PLUGIN_PARAM_OTEL_MEMORY_REQUEST, split.OTel.MemoryRequest) addPluginParamIfSet(params, util.PLUGIN_PARAM_OTEL_MEMORY_LIMIT, split.OTel.MemoryLimit) diff --git a/operator/src/internal/product/documentdb.go b/operator/src/internal/product/documentdb.go index b40c2c35a..eec743913 100644 --- a/operator/src/internal/product/documentdb.go +++ b/operator/src/internal/product/documentdb.go @@ -16,10 +16,9 @@ import ( func DocumentDBProfile() ProductProfile { return ProductProfile{ Name: "DocumentDB", - ExtensionImageRepo: util.DOCUMENTDB_EXTENSION_IMAGE_REPO, - GatewayImageRepo: util.GATEWAY_IMAGE_REPO, - DefaultExtensionImage: util.DEFAULT_DOCUMENTDB_IMAGE, - DefaultGatewayImage: util.DEFAULT_GATEWAY_IMAGE, + ExtensionImageRepo: util.ExtensionImageRepo(), + GatewayImageRepo: util.GatewayImageRepo(), + DefaultTag: util.DEFAULT_DOCUMENTDB_TAG, DefaultCredentialSecret: util.DEFAULT_DOCUMENTDB_CREDENTIALS_SECRET, SidecarInjectorPlugin: util.DEFAULT_SIDECAR_INJECTOR_PLUGIN, WALReplicaPlugin: util.DEFAULT_WAL_REPLICA_PLUGIN, @@ -46,7 +45,7 @@ func (a DocumentDBAdapter) ExtensionImage(db *dbpreview.DocumentDB) string { } return util.ResolveComponentImage( p.ExtensionImageRepo, - p.DefaultExtensionImage, + p.DefaultTag, explicit, db.Spec.DocumentDBVersion, os.Getenv(util.DOCUMENTDB_VERSION_ENV), @@ -65,7 +64,7 @@ func (a DocumentDBAdapter) GatewayImage(db *dbpreview.DocumentDB) string { } return util.ResolveComponentImage( p.GatewayImageRepo, - p.DefaultGatewayImage, + p.DefaultTag, explicit, db.Spec.DocumentDBVersion, os.Getenv(util.DOCUMENTDB_VERSION_ENV), @@ -91,8 +90,14 @@ func (a DocumentDBAdapter) ToClusterIntent(db *dbpreview.DocumentDB) ClusterInte } var postgresImage string - if db.Spec.Image != nil { + if db.Spec.Image != nil && db.Spec.Image.Postgres != "" { + // A CR-pinned operand image wins. postgresImage = db.Spec.Image.Postgres + } else { + // Otherwise use the operator-level default (POSTGRES_IMAGE env). When that + // is empty the field stays empty and CloudNativePG applies its built-in + // operand default. + postgresImage = util.PostgresImage() } var pg Postgres diff --git a/operator/src/internal/product/documentdb_test.go b/operator/src/internal/product/documentdb_test.go index 9424b4a02..72fa07370 100644 --- a/operator/src/internal/product/documentdb_test.go +++ b/operator/src/internal/product/documentdb_test.go @@ -88,3 +88,52 @@ func TestDocumentDBAdapterImageResolutionEnvVar(t *testing.T) { t.Errorf("GatewayImage() = %q, want %q", got, want) } } + +// TestDocumentDBAdapterRepoOverrideFlowsToDefault confirms that a repository env +// override is honored on the default-tag path (no explicit image and no version), +// so a mirrored registry stays consistent for the default image too. +func TestDocumentDBAdapterRepoOverrideFlowsToDefault(t *testing.T) { + t.Setenv(util.DOCUMENTDB_EXTENSION_IMAGE_REPO_ENV, "mcr.microsoft.com/documentdb/documentdb") + t.Setenv(util.GATEWAY_IMAGE_REPO_ENV, "mcr.microsoft.com/documentdb/gateway") + a := DocumentDBAdapter{} + db := &dbpreview.DocumentDB{Spec: dbpreview.DocumentDBSpec{}} + + if got, want := a.ExtensionImage(db), "mcr.microsoft.com/documentdb/documentdb:"+util.DEFAULT_DOCUMENTDB_TAG; got != want { + t.Errorf("ExtensionImage() = %q, want %q", got, want) + } + if got, want := a.GatewayImage(db), "mcr.microsoft.com/documentdb/gateway:"+util.DEFAULT_DOCUMENTDB_TAG; got != want { + t.Errorf("GatewayImage() = %q, want %q", got, want) + } +} + +// TestDocumentDBAdapterPostgresImage covers the base PostgreSQL operand image +// resolution: a CR-pinned image wins, else the operator-level POSTGRES_IMAGE +// default, else empty (defer to CloudNativePG's built-in operand default). +func TestDocumentDBAdapterPostgresImage(t *testing.T) { + a := DocumentDBAdapter{} + + t.Run("empty when unset defers to CNPG", func(t *testing.T) { + got := a.ToClusterIntent(&dbpreview.DocumentDB{Spec: dbpreview.DocumentDBSpec{}}).Images.Postgres + if got != "" { + t.Errorf("Postgres = %q, want empty", got) + } + }) + + t.Run("operator default from env", func(t *testing.T) { + t.Setenv(util.POSTGRES_IMAGE_ENV, "mcr.microsoft.com/oss/cloudnative-pg/postgresql:17.6") + got := a.ToClusterIntent(&dbpreview.DocumentDB{Spec: dbpreview.DocumentDBSpec{}}).Images.Postgres + if want := "mcr.microsoft.com/oss/cloudnative-pg/postgresql:17.6"; got != want { + t.Errorf("Postgres = %q, want %q", got, want) + } + }) + + t.Run("CR pin overrides operator default", func(t *testing.T) { + t.Setenv(util.POSTGRES_IMAGE_ENV, "mcr.microsoft.com/oss/cloudnative-pg/postgresql:17.6") + db := &dbpreview.DocumentDB{Spec: dbpreview.DocumentDBSpec{ + Image: &dbpreview.ImageSpec{Postgres: "custom-registry/pg:16"}, + }} + if got, want := a.ToClusterIntent(db).Images.Postgres, "custom-registry/pg:16"; got != want { + t.Errorf("Postgres = %q, want %q", got, want) + } + }) +} diff --git a/operator/src/internal/product/profile.go b/operator/src/internal/product/profile.go index aa6d233da..4936c367b 100644 --- a/operator/src/internal/product/profile.go +++ b/operator/src/internal/product/profile.go @@ -22,10 +22,10 @@ type ProductProfile struct { ExtensionImageRepo string GatewayImageRepo string - // DefaultExtensionImage and DefaultGatewayImage are the fully-qualified - // images used when neither an explicit image nor a version is supplied. - DefaultExtensionImage string - DefaultGatewayImage string + // DefaultTag is the image tag used when neither an explicit image nor a + // version is supplied. It is composed onto the (possibly overridden) + // repository so a registry override flows through to the default image. + DefaultTag string // DefaultCredentialSecret is the credential secret name used when the custom // resource does not specify one. diff --git a/operator/src/internal/product/profile_test.go b/operator/src/internal/product/profile_test.go index a882fc4ed..7d9fa458a 100644 --- a/operator/src/internal/product/profile_test.go +++ b/operator/src/internal/product/profile_test.go @@ -24,8 +24,7 @@ func TestDocumentDBProfilePinsConstants(t *testing.T) { {"Name", p.Name, "DocumentDB"}, {"ExtensionImageRepo", p.ExtensionImageRepo, util.DOCUMENTDB_EXTENSION_IMAGE_REPO}, {"GatewayImageRepo", p.GatewayImageRepo, util.GATEWAY_IMAGE_REPO}, - {"DefaultExtensionImage", p.DefaultExtensionImage, util.DEFAULT_DOCUMENTDB_IMAGE}, - {"DefaultGatewayImage", p.DefaultGatewayImage, util.DEFAULT_GATEWAY_IMAGE}, + {"DefaultTag", p.DefaultTag, util.DEFAULT_DOCUMENTDB_TAG}, {"DefaultCredentialSecret", p.DefaultCredentialSecret, util.DEFAULT_DOCUMENTDB_CREDENTIALS_SECRET}, {"SidecarInjectorPlugin", p.SidecarInjectorPlugin, util.DEFAULT_SIDECAR_INJECTOR_PLUGIN}, {"WALReplicaPlugin", p.WALReplicaPlugin, util.DEFAULT_WAL_REPLICA_PLUGIN}, diff --git a/operator/src/internal/utils/constants.go b/operator/src/internal/utils/constants.go index 975cd3ff1..7d93510af 100644 --- a/operator/src/internal/utils/constants.go +++ b/operator/src/internal/utils/constants.go @@ -17,6 +17,26 @@ const ( // DocumentDB extension image pull policy environment variable DOCUMENTDB_IMAGE_PULL_POLICY_ENV = "DOCUMENTDB_IMAGE_PULL_POLICY" + // --- Configurable image sources (registry-prefix model) --- + // These env vars let the Helm chart supply the image repositories/refs at + // deploy time so the same operator binary can be repointed at a different + // registry (e.g. a public registry upstream, or a private/mirrored registry) + // without a code change. Each is optional; when unset the operator falls back + // to its compiled-in default below. + + // DOCUMENTDB_EXTENSION_IMAGE_REPO_ENV overrides the extension image repository + // (host+path, no tag) used when composing an image from a bare version. + DOCUMENTDB_EXTENSION_IMAGE_REPO_ENV = "DOCUMENTDB_EXTENSION_IMAGE_REPO" + // GATEWAY_IMAGE_REPO_ENV overrides the gateway image repository (host+path, no tag). + GATEWAY_IMAGE_REPO_ENV = "GATEWAY_IMAGE_REPO" + // OTEL_COLLECTOR_IMAGE_ENV overrides the fully-qualified OTel collector image + // (host+path+tag) injected into the monitoring sidecar. + OTEL_COLLECTOR_IMAGE_ENV = "OTEL_COLLECTOR_IMAGE" + // POSTGRES_IMAGE_ENV supplies a fully-qualified base PostgreSQL operand image. + // When unset (and the CR does not pin spec.image.Postgres) the operator defers + // to CloudNativePG's built-in operand default. + POSTGRES_IMAGE_ENV = "POSTGRES_IMAGE" + // IOURING_SECCOMP_PROFILE_ENV overrides the Localhost seccomp profile path // applied to the postgres pods when the IOUring feature gate is enabled. The // path is relative to the node's kubelet seccomp root (/var/lib/kubelet/seccomp). @@ -32,10 +52,14 @@ const ( DOCUMENTDB_EXTENSION_IMAGE_REPO = "ghcr.io/documentdb/documentdb-kubernetes-operator/documentdb" GATEWAY_IMAGE_REPO = "ghcr.io/documentdb/documentdb-kubernetes-operator/gateway" + // DEFAULT_DOCUMENTDB_TAG is the default tag for the extension and gateway + // images when neither an explicit image nor a version is supplied. + DEFAULT_DOCUMENTDB_TAG = "0.117.0" + // DEFAULT_DOCUMENTDB_IMAGE is the extension image used in ImageVolume mode. - DEFAULT_DOCUMENTDB_IMAGE = DOCUMENTDB_EXTENSION_IMAGE_REPO + ":0.117.0" + DEFAULT_DOCUMENTDB_IMAGE = DOCUMENTDB_EXTENSION_IMAGE_REPO + ":" + DEFAULT_DOCUMENTDB_TAG // NOTE: Keep in sync with operator/cnpg-plugins/sidecar-injector/internal/config/config.go:applyDefaults() - DEFAULT_GATEWAY_IMAGE = GATEWAY_IMAGE_REPO + ":0.117.0" + DEFAULT_GATEWAY_IMAGE = GATEWAY_IMAGE_REPO + ":" + DEFAULT_DOCUMENTDB_TAG DEFAULT_DOCUMENTDB_CREDENTIALS_SECRET = "documentdb-credentials" DEFAULT_OTEL_COLLECTOR_IMAGE = "otel/opentelemetry-collector-contrib:0.149.0" diff --git a/operator/src/internal/utils/image_source_test.go b/operator/src/internal/utils/image_source_test.go new file mode 100644 index 000000000..90ece2f97 --- /dev/null +++ b/operator/src/internal/utils/image_source_test.go @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package util + +import "testing" + +// TestImageRepoGettersDefault verifies the getters return the compiled-in +// defaults when their env overrides are unset, preserving existing behavior. +func TestImageRepoGettersDefault(t *testing.T) { + cases := []struct { + name string + got string + want string + }{ + {"ExtensionImageRepo", ExtensionImageRepo(), DOCUMENTDB_EXTENSION_IMAGE_REPO}, + {"GatewayImageRepo", GatewayImageRepo(), GATEWAY_IMAGE_REPO}, + {"OtelCollectorImage", OtelCollectorImage(), DEFAULT_OTEL_COLLECTOR_IMAGE}, + {"PostgresImage", PostgresImage(), ""}, + } + for _, c := range cases { + if c.got != c.want { + t.Errorf("%s = %q, want %q", c.name, c.got, c.want) + } + } +} + +// TestImageRepoGettersEnvOverride verifies each getter honors its env override. +func TestImageRepoGettersEnvOverride(t *testing.T) { + t.Setenv(DOCUMENTDB_EXTENSION_IMAGE_REPO_ENV, "mcr.microsoft.com/documentdb/documentdb") + t.Setenv(GATEWAY_IMAGE_REPO_ENV, "mcr.microsoft.com/documentdb/gateway") + t.Setenv(OTEL_COLLECTOR_IMAGE_ENV, "mcr.microsoft.com/oss/otel/opentelemetry-collector-contrib:0.149.0") + t.Setenv(POSTGRES_IMAGE_ENV, "mcr.microsoft.com/oss/cloudnative-pg/postgresql:17.6") + + cases := []struct { + name string + got string + want string + }{ + {"ExtensionImageRepo", ExtensionImageRepo(), "mcr.microsoft.com/documentdb/documentdb"}, + {"GatewayImageRepo", GatewayImageRepo(), "mcr.microsoft.com/documentdb/gateway"}, + {"OtelCollectorImage", OtelCollectorImage(), "mcr.microsoft.com/oss/otel/opentelemetry-collector-contrib:0.149.0"}, + {"PostgresImage", PostgresImage(), "mcr.microsoft.com/oss/cloudnative-pg/postgresql:17.6"}, + } + for _, c := range cases { + if c.got != c.want { + t.Errorf("%s = %q, want %q", c.name, c.got, c.want) + } + } +} diff --git a/operator/src/internal/utils/util.go b/operator/src/internal/utils/util.go index 06136716d..ed7650312 100644 --- a/operator/src/internal/utils/util.go +++ b/operator/src/internal/utils/util.go @@ -421,27 +421,65 @@ func GenerateConnectionString(documentdb *dbpreview.DocumentDB, serviceIp string return conn + "&replicaSet=rs0" } +// envOr returns the value of the environment variable named key, or def when it +// is unset or empty. +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// ExtensionImageRepo returns the DocumentDB extension image repository (host+path, +// no tag), honoring the DOCUMENTDB_EXTENSION_IMAGE_REPO env override and falling +// back to the compiled-in default. +func ExtensionImageRepo() string { + return envOr(DOCUMENTDB_EXTENSION_IMAGE_REPO_ENV, DOCUMENTDB_EXTENSION_IMAGE_REPO) +} + +// GatewayImageRepo returns the gateway image repository (host+path, no tag), +// honoring the GATEWAY_IMAGE_REPO env override. +func GatewayImageRepo() string { + return envOr(GATEWAY_IMAGE_REPO_ENV, GATEWAY_IMAGE_REPO) +} + +// OtelCollectorImage returns the fully-qualified OTel collector image injected +// into the monitoring sidecar, honoring the OTEL_COLLECTOR_IMAGE env override. +func OtelCollectorImage() string { + return envOr(OTEL_COLLECTOR_IMAGE_ENV, DEFAULT_OTEL_COLLECTOR_IMAGE) +} + +// PostgresImage returns the operator-level base PostgreSQL operand image supplied +// via the POSTGRES_IMAGE env, or empty when unset. An empty result means the +// operator defers to CloudNativePG's built-in operand default (unless the CR +// pins spec.image.Postgres). +func PostgresImage() string { + return os.Getenv(POSTGRES_IMAGE_ENV) +} + // ResolveComponentImage applies the shared image-resolution priority used for -// both the extension and gateway images: an explicit image wins, then a -// spec-level version, then an environment-provided version, then the -// change-stream override, and finally the product default. Every product-varying -// value (repo, default, env version, change-stream image) is supplied by the -// caller so this function stays product-neutral and serves any product profile. -func ResolveComponentImage(repo, defaultImage, explicitImage, specVersion, envVersion, changeStreamImage string, changeStreamEnabled bool) string { +// both the extension and gateway images: an explicit image wins, then a version +// (spec-level, else environment-provided) composed onto the repository, then the +// change-stream override, and finally the repository at the default tag. Every +// product-varying value (repo, default tag, env version, change-stream image) is +// supplied by the caller so this function stays product-neutral and serves any +// product profile. +func ResolveComponentImage(repo, defaultTag, explicitImage, specVersion, envVersion, changeStreamImage string, changeStreamEnabled bool) string { if explicitImage != "" { return explicitImage } - if specVersion != "" { - return fmt.Sprintf("%s:%s", repo, specVersion) + version := specVersion + if version == "" { + version = envVersion } - if envVersion != "" { - return fmt.Sprintf("%s:%s", repo, envVersion) + if version != "" { + return fmt.Sprintf("%s:%s", repo, version) } // TODO: remove this override once change stream support is included in the official images. if changeStreamEnabled { return changeStreamImage } - return defaultImage + return fmt.Sprintf("%s:%s", repo, defaultTag) } func GenerateServiceName(source, target, resourceGroup string) string {