From dcedbe69a4100c7226ea077e03a713c8d2fbfadd Mon Sep 17 00:00:00 2001 From: Martin Jackson Date: Fri, 2 Oct 2026 10:57:08 -0500 Subject: [PATCH 1/9] Integrate MCG support and include config framework --- .trivyignore | 7 + Chart.yaml | 2 +- README.md | 103 +++++++-- README.md.gotmpl | 11 + files/quay-config-install.sh | 14 ++ files/quay-config-run.sh | 58 +++++ files/quay-icon.png | Bin 0 -> 19769 bytes files/s3-credentials-setup.sh | 80 +++++++ templates/_helpers.tpl | 230 ++++++++++++++++++++ templates/console-link.yaml | 15 ++ templates/mcg-backingstore.yaml | 21 ++ templates/mcg-bucketclass.yaml | 18 ++ templates/mcg-noobaa.yaml | 18 ++ templates/object-bucket-claim.yaml | 6 +- templates/quay-config-bundle-secret.yaml | 19 +- templates/quay-config-configmap.yaml | 21 ++ templates/quay-config-cronjob.yaml | 21 ++ templates/quay-config-externalsecret.yaml | 24 ++ templates/quay-config-job.yaml | 15 ++ templates/quay-config-rbac.yaml | 37 ++++ templates/quay-registry.yaml | 6 +- templates/quay-s3-credentials-job.yaml | 141 ++++-------- templates/quay-s3-setup-serviceaccount.yaml | 6 +- tests/config_bundle_test.yaml | 18 ++ tests/console_link_test.yaml | 51 +++++ tests/object_bucket_claim_test.yaml | 26 +++ tests/object_storage_test.yaml | 60 +++++ tests/quay_config_cronjob_test.yaml | 24 ++ tests/quay_config_job_test.yaml | 37 ++++ tests/quay_config_playbook_test.yaml | 21 ++ tests/quay_config_secret_test.yaml | 36 +++ tests/quay_registry_test.yaml | 15 ++ tests/s3_credentials_job_test.yaml | 21 ++ values.yaml | 150 +++++++++++-- 34 files changed, 1179 insertions(+), 153 deletions(-) create mode 100755 files/quay-config-install.sh create mode 100755 files/quay-config-run.sh create mode 100644 files/quay-icon.png create mode 100755 files/s3-credentials-setup.sh create mode 100644 templates/_helpers.tpl create mode 100644 templates/console-link.yaml create mode 100644 templates/mcg-backingstore.yaml create mode 100644 templates/mcg-bucketclass.yaml create mode 100644 templates/mcg-noobaa.yaml create mode 100644 templates/quay-config-configmap.yaml create mode 100644 templates/quay-config-cronjob.yaml create mode 100644 templates/quay-config-externalsecret.yaml create mode 100644 templates/quay-config-job.yaml create mode 100644 templates/quay-config-rbac.yaml create mode 100644 tests/config_bundle_test.yaml create mode 100644 tests/console_link_test.yaml create mode 100644 tests/object_bucket_claim_test.yaml create mode 100644 tests/object_storage_test.yaml create mode 100644 tests/quay_config_cronjob_test.yaml create mode 100644 tests/quay_config_job_test.yaml create mode 100644 tests/quay_config_playbook_test.yaml create mode 100644 tests/quay_config_secret_test.yaml create mode 100644 tests/quay_registry_test.yaml create mode 100644 tests/s3_credentials_job_test.yaml diff --git a/.trivyignore b/.trivyignore index a33ed9b..3c1cdd2 100644 --- a/.trivyignore +++ b/.trivyignore @@ -1,8 +1,15 @@ # AVD-KSV-0125: S3 job uses OpenShift built-in cli ImageStream (cluster-internal registry) AVD-KSV-0125 +# AVD-KSV-0013: same ImageStream reference has no fixed tag; it tracks the cluster +AVD-KSV-0013 # AVD-KSV-0113: S3 setup Role intentionally needs secret get/create/patch for quay config bundle AVD-KSV-0113 # AVD-KSV-0020: Job uses OpenShift namespace default UID at runtime (high UID, no anyuid needed) AVD-KSV-0020 # AVD-KSV-0021: Job uses OpenShift namespace default GID at runtime AVD-KSV-0021 +# AVD-KSV-0109: Ansible playbook parameter names (password) live in the ConfigMap. +# Secret values are file lookups from an ExternalSecret, not literals. +AVD-KSV-0109 +# AVD-KSV-01010: playbook also names usernames and emails. Those are not credentials. +AVD-KSV-01010 diff --git a/Chart.yaml b/Chart.yaml index 0b5f793..c3cc415 100644 --- a/Chart.yaml +++ b/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: quay description: Red Hat Quay Registry Resources type: application -version: 0.1.3 +version: 0.2.0 appVersion: "3.9" home: https://github.com/validatedpatterns/quay-chart maintainers: diff --git a/README.md b/README.md index c2a76eb..f00f69f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ -![Version: 0.1.3](https://img.shields.io/badge/Version-0.1.3-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 3.9](https://img.shields.io/badge/AppVersion-3.9-informational?style=flat-square) +![Version: 0.2.0](https://img.shields.io/badge/Version-0.2.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 3.9](https://img.shields.io/badge/AppVersion-3.9-informational?style=flat-square) @@ -16,6 +16,17 @@ This chart is used to serve as the template for Validated Patterns Charts ## Notable changes +### 0.2.0 + +- Standalone Multicloud Object Gateway is the default object storage + backend (`objectStorage.mode=mcg`). Set `objectStorage.mode=odf` to use + an existing OpenShift Data Foundation StorageCluster instead. +- An OpenShift console link points at the Quay route and embeds the Quay icon. +- Users, organizations, and repositories are applied by a Job and CronJob + using the `infra.quay_configuration` Ansible collection. Passwords are + projected with an ExternalSecret. +- `quay.setup` and `quay_config` moved to `quayConfig`. + **Homepage:** ## Maintainers @@ -28,28 +39,74 @@ This chart is used to serve as the template for Validated Patterns Charts ## Values -| Key | Type | Default | Description | -| -------------------------------------------- | ------ | ------------------------------------------------------------------ | ----------- | -| job.image | string | `"image-registry.openshift-image-registry.svc:5000/openshift/cli"` | | -| job.resources.limits.cpu | string | `"500m"` | | -| job.resources.limits.memory | string | `"256Mi"` | | -| job.resources.requests.cpu | string | `"50m"` | | -| job.resources.requests.memory | string | `"128Mi"` | | -| objectStorage.objectBucketClaim.bucketName | string | `"quay-datastore"` | | -| objectStorage.objectBucketClaim.name | string | `"quay-bucket"` | | -| objectStorage.objectBucketClaim.storageClass | string | `"openshift-storage.noobaa.io"` | | -| quay.configBundleSecret.deploy | bool | `true` | | -| quay.configBundleSecret.name | string | `"quay-init-config-bundle-secret"` | | -| quay.namespace | string | `"quay-enterprise"` | | -| quay.setup.admin.email | string | `"quayadmin@example.com"` | | -| quay.setup.admin.name | string | `"quayadmin"` | | -| quay.setup.user.email | string | `"developer1@myorg.com"` | | -| quay.setup.user.name | string | `"developer1"` | | -| quay.storage.clairpostgres.size | string | `"50Gi"` | | -| quay.storage.postgres.size | string | `"50Gi"` | | -| quay_config.org.email | string | `"devel@myorg.com"` | | -| quay_config.org.name | string | `"devel"` | | -| quay_config.repo | string | `"example"` | | +| Key | Type | Default | Description | +| ----------------------------------------------------------------- | ------ | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| configJob.activeDeadlineSeconds | int | `1800` | Job activeDeadlineSeconds for the bootstrap Job and CronJob pods. | +| configJob.backoffLimit | int | `5` | | +| configJob.collectionVersion | string | `"2.8.1"` | infra.quay_configuration version passed to ansible-galaxy. | +| configJob.enabled | bool | `true` | Run the bootstrap Job and the reconciling CronJob. | +| configJob.failedJobsHistoryLimit | int | `1` | | +| configJob.image | string | `"quay.io/hybridcloudpatterns/imperative-container:v1"` | Image with ansible-core, ansible-galaxy, oc, and cURL. | +| configJob.imagePullPolicy | string | `"Always"` | | +| configJob.installCollection | bool | `true` | Install infra.quay_configuration into an emptyDir before the playbook. Set false when configJob.image already contains the collection. | +| configJob.resources.limits.cpu | string | `"500m"` | | +| configJob.resources.limits.memory | string | `"512Mi"` | | +| configJob.resources.requests.cpu | string | `"50m"` | | +| configJob.resources.requests.memory | string | `"256Mi"` | | +| configJob.schedule | string | `"*/30 * * * *"` | Cron schedule for re-applying Quay configuration. | +| configJob.successfulJobsHistoryLimit | int | `1` | | +| configJob.validateCerts | bool | `false` | Verify the Quay route TLS certificate. In-cluster routes often use a private CA, so the default is false. | +| consoleLink.enabled | bool | `true` | Create an ApplicationMenu ConsoleLink for the Quay route. | +| consoleLink.href | string | `""` | Override the computed route URL. Empty builds the managed route from quay.name, quay.namespace, and global.clusterDomain. | +| consoleLink.name | string | `"quay"` | ConsoleLink metadata.name. | +| consoleLink.section | string | `"Red Hat applications"` | Application menu section. | +| consoleLink.text | string | `"Red Hat Quay"` | Menu text. | +| global.clusterDomain | string | `"example.com"` | OpenShift cluster base domain. Used in the console link when consoleLink.href is empty. The host is apps. plus this domain. | +| job.image | string | `"image-registry.openshift-image-registry.svc:5000/openshift/cli"` | Image for the S3 credentials Job. Uses the OpenShift cli ImageStream, which tracks the cluster version. Override when the internal registry is unavailable, for example ose-cli-rhel9:v4.20. | +| job.resources.limits.cpu | string | `"500m"` | | +| job.resources.limits.memory | string | `"256Mi"` | | +| job.resources.requests.cpu | string | `"50m"` | | +| job.resources.requests.memory | string | `"128Mi"` | | +| objectStorage.mcg.backingStore.name | string | `"noobaa-default-backing-store"` | BackingStore that holds the gateway's persistent volumes. | +| objectStorage.mcg.bucketClass.name | string | `"noobaa-default-bucket-class"` | BucketClass used by the default NooBaa storage class. | +| objectStorage.mcg.bucketClass.placement.tiers[0].backingStores[0] | string | `"noobaa-default-backing-store"` | | +| objectStorage.mcg.dbSize | string | `"50Gi"` | Size of the NooBaa PostgreSQL volume. | +| objectStorage.mcg.namespace | string | `"openshift-storage"` | Namespace of the NooBaa system. Must match the ODF operator namespace. | +| objectStorage.mcg.pvPool.numVolumes | int | `1` | Number of persistent volumes in the backing store pool. | +| objectStorage.mcg.pvPool.resources.limits.cpu | string | `"1"` | | +| objectStorage.mcg.pvPool.resources.limits.memory | string | `"4Gi"` | | +| objectStorage.mcg.pvPool.resources.requests.cpu | string | `"800m"` | | +| objectStorage.mcg.pvPool.resources.requests.memory | string | `"800Mi"` | | +| objectStorage.mcg.pvPool.resources.requests.storage | string | `"50Gi"` | | +| objectStorage.mcg.system.name | string | `"noobaa"` | Name of the NooBaa custom resource. | +| objectStorage.mode | string | `"mcg"` | mcg deploys the standalone Multicloud Object Gateway. odf consumes an existing OpenShift Data Foundation StorageCluster. | +| objectStorage.objectBucketClaim.bucketName | string | `"quay-datastore"` | Prefix passed to generateBucketName. | +| objectStorage.objectBucketClaim.name | string | `"quay-bucket"` | ObjectBucketClaim name. The bound ConfigMap and Secret use this name. | +| objectStorage.objectBucketClaim.storageClass | string | `"openshift-storage.noobaa.io"` | StorageClass for the claim. Override for Ceph RGW, for example ocs-storagecluster-ceph-rgw. | +| quay.configBundleSecret.deploy | bool | `true` | | +| quay.configBundleSecret.name | string | `"quay-init-config-bundle-secret"` | Template secret. The S3 job copies it and fills storage placeholders. | +| quay.configBundleSecret.s3Name | string | `"quay-config-with-s3"` | Secret QuayRegistry reads after the S3 job fills credentials. | +| quay.name | string | `"quay-registry"` | Name of the QuayRegistry resource. The managed route is this name with -quay appended. | +| quay.namespace | string | `"quay-enterprise"` | Namespace for the Quay registry and its configuration jobs. | +| quay.storage.clairpostgres.size | string | `"50Gi"` | Persistent volume size for the Clair PostgreSQL database. | +| quay.storage.postgres.size | string | `"50Gi"` | Persistent volume size for the Quay PostgreSQL database. | +| quayConfig.credentials.key | string | `"secret/data/hub/infra/quay/quay-users"` | Vault (or other backend) path extracted into quay-config-credentials. | +| quayConfig.organizations[0].email | string | `"devel@myorg.com"` | | +| quayConfig.organizations[0].name | string | `"devel"` | | +| quayConfig.repositories[0].name | string | `"devel/example"` | | +| quayConfig.repositories[0].visibility | string | `"private"` | | +| quayConfig.users[0].email | string | `"quayadmin@example.com"` | | +| quayConfig.users[0].initialize | bool | `true` | | +| quayConfig.users[0].name | string | `"quayadmin"` | | +| quayConfig.users[0].passwordProperty | string | `"quay-admin-password"` | Property on credentials.key projected into the credentials Secret. | +| quayConfig.users[0].superuser | bool | `true` | | +| quayConfig.users[1].email | string | `"developer1@myorg.com"` | | +| quayConfig.users[1].initialize | bool | `false` | | +| quayConfig.users[1].name | string | `"developer1"` | | +| quayConfig.users[1].passwordProperty | string | `"quay-user-password"` | | +| quayConfig.users[1].superuser | bool | `false` | | +| secretStore.kind | string | `"ClusterSecretStore"` | Kind of secretStore.name. | +| secretStore.name | string | `"vault-backend"` | SecretStore or ClusterSecretStore that holds Quay user passwords. | diff --git a/README.md.gotmpl b/README.md.gotmpl index c426f84..a99d2a8 100644 --- a/README.md.gotmpl +++ b/README.md.gotmpl @@ -13,6 +13,17 @@ This chart is used to serve as the template for Validated Patterns Charts ## Notable changes +### 0.2.0 + +- Standalone Multicloud Object Gateway is the default object storage + backend (`objectStorage.mode=mcg`). Set `objectStorage.mode=odf` to use + an existing OpenShift Data Foundation StorageCluster instead. +- An OpenShift console link points at the Quay route and embeds the Quay icon. +- Users, organizations, and repositories are applied by a Job and CronJob + using the `infra.quay_configuration` Ansible collection. Passwords are + projected with an ExternalSecret. +- `quay.setup` and `quay_config` moved to `quayConfig`. + {{ template "chart.homepageLine" . }} {{ template "chart.maintainersSection" . }} diff --git a/files/quay-config-install.sh b/files/quay-config-install.sh new file mode 100755 index 0000000..6e9ea59 --- /dev/null +++ b/files/quay-config-install.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Install infra.quay_configuration onto the shared work volume. +set -euo pipefail + +collections_path="${COLLECTIONS_PATH:?COLLECTIONS_PATH is required}" +requirements="/quay-config/requirements.yml" + +if [[ ! -f "${requirements}" ]]; then + echo "ERROR: ${requirements} is missing" >&2 + exit 1 +fi + +mkdir -p "${collections_path}" +ansible-galaxy collection install -r "${requirements}" -p "${collections_path}" diff --git a/files/quay-config-run.sh b/files/quay-config-run.sh new file mode 100755 index 0000000..e66e93b --- /dev/null +++ b/files/quay-config-run.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Wait for the Quay route, then apply the configuration playbook. +set -euo pipefail + +quay_namespace="${QUAY_NAMESPACE:?QUAY_NAMESPACE is required}" +quay_route="${QUAY_ROUTE:?QUAY_ROUTE is required}" +install_collection="${INSTALL_COLLECTION:?INSTALL_COLLECTION is required}" +validate_certs="${VALIDATE_CERTS:?VALIDATE_CERTS is required}" +home_dir="${HOME_DIR:-/quay-work}" + +route_host="" +echo "Waiting for route ${quay_route} in ${quay_namespace}..." +for _ in $(seq 1 60); do + route_host="$( + oc get route "${quay_route}" -n "${quay_namespace}" \ + -o jsonpath='{.status.ingress[0].host}' 2>/dev/null || true + )" + if [[ -n "${route_host}" ]]; then + break + fi + sleep 10 +done + +if [[ -z "${route_host}" ]]; then + echo "ERROR: route ${quay_route} has no admitted host" >&2 + oc describe route "${quay_route}" -n "${quay_namespace}" || true + exit 1 +fi + +quay_host="https://${route_host}" +echo "Quay host is ${quay_host}" + +ready="" +for _ in $(seq 1 60); do + if curl -kfsS --max-time 5 "${quay_host}/health/instance" >/dev/null; then + ready="yes" + break + fi + sleep 10 +done + +if [[ -z "${ready}" ]]; then + echo "ERROR: Quay health endpoint did not become ready" >&2 + exit 1 +fi + +if [[ "${install_collection}" == "true" ]]; then + collections_path="${COLLECTIONS_PATH:?COLLECTIONS_PATH is required}" + export ANSIBLE_COLLECTIONS_PATH="${collections_path}" +fi + +export HOME="${home_dir}" +export ANSIBLE_LOCAL_TEMP="${HOME}/tmp" +mkdir -p "${ANSIBLE_LOCAL_TEMP}" + +ansible-playbook /quay-config/playbook.yml \ + -e "quay_host=${quay_host}" \ + -e "validate_certs=${validate_certs}" diff --git a/files/quay-icon.png b/files/quay-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8ad64a11b808f11e69419e0c290e0ba2e71b14ef GIT binary patch literal 19769 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 WfepM)I{M3>(ZOMsMzsc>@&5zkQp0Kh literal 0 HcmV?d00001 diff --git a/files/s3-credentials-setup.sh b/files/s3-credentials-setup.sh new file mode 100755 index 0000000..4af18e5 --- /dev/null +++ b/files/s3-credentials-setup.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# Fill the Quay config template from a bound ObjectBucketClaim. +set -euo pipefail + +quay_namespace="${QUAY_NAMESPACE:?QUAY_NAMESPACE is required}" +obc_name="${OBC_NAME:?OBC_NAME is required}" +config_secret="${CONFIG_SECRET:?CONFIG_SECRET is required}" +output_secret="${OUTPUT_SECRET:?OUTPUT_SECRET is required}" + +echo "Setting up S3 credentials for Quay from ObjectBucketClaim ${obc_name}..." + +oc get objectbucketclaim "${obc_name}" -n "${quay_namespace}" + +echo "Waiting for ObjectBucketClaim ${obc_name} to be Bound (timeout: 10 minutes)..." +if ! oc wait --for=jsonpath='{.status.phase}'=Bound \ + "objectbucketclaim/${obc_name}" -n "${quay_namespace}" --timeout=600s; then + echo "ERROR: ObjectBucketClaim failed to reach Bound state within timeout" >&2 + oc describe objectbucketclaim "${obc_name}" -n "${quay_namespace}" + exit 1 +fi + +access_key="$( + oc get secret "${obc_name}" -n "${quay_namespace}" \ + -o jsonpath='{.data.AWS_ACCESS_KEY_ID}' | base64 -d +)" +secret_key="$( + oc get secret "${obc_name}" -n "${quay_namespace}" \ + -o jsonpath='{.data.AWS_SECRET_ACCESS_KEY}' | base64 -d +)" +bucket_name="$( + oc get configmap "${obc_name}" -n "${quay_namespace}" \ + -o jsonpath='{.data.BUCKET_NAME}' +)" +bucket_host="$( + oc get configmap "${obc_name}" -n "${quay_namespace}" \ + -o jsonpath='{.data.BUCKET_HOST}' +)" +bucket_port="$( + oc get configmap "${obc_name}" -n "${quay_namespace}" \ + -o jsonpath='{.data.BUCKET_PORT}' +)" + +if [[ -z "${bucket_port}" ]]; then + bucket_port="443" +fi + +if [[ "${bucket_port}" == "443" ]]; then + is_secure="true" +else + is_secure="false" +fi + +if [[ -z "${bucket_host}" || -z "${bucket_name}" || -z "${access_key}" || -z "${secret_key}" ]]; then + echo "ERROR: ObjectBucketClaim ${obc_name} is missing endpoint or credentials" >&2 + exit 1 +fi + +echo "Retrieved S3 credentials successfully" +echo "Bucket: ${bucket_name}" +echo "Endpoint: ${bucket_host}:${bucket_port}" + +oc get secret "${config_secret}" -n "${quay_namespace}" \ + -o jsonpath='{.data.config\.yaml}' | base64 -d >/tmp/config.yaml + +sed -i \ + -e "s|PLACEHOLDER_ACCESS_KEY|${access_key}|g" \ + -e "s|PLACEHOLDER_SECRET_KEY|${secret_key}|g" \ + -e "s|PLACEHOLDER_BUCKET_NAME|${bucket_name}|g" \ + -e "s|PLACEHOLDER_BUCKET_HOST|${bucket_host}|g" \ + -e "s|PLACEHOLDER_BUCKET_PORT|${bucket_port}|g" \ + -e "s|PLACEHOLDER_IS_SECURE|${is_secure}|g" \ + /tmp/config.yaml + +echo "Creating ${output_secret} with credentials from the ObjectBucketClaim..." +oc create secret generic "${output_secret}" \ + --from-file=config.yaml=/tmp/config.yaml \ + -n "${quay_namespace}" \ + --dry-run=client -o yaml | oc apply -f - + +echo "Quay S3 credentials setup completed successfully" diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl new file mode 100644 index 0000000..d722c32 --- /dev/null +++ b/templates/_helpers.tpl @@ -0,0 +1,230 @@ +{{/* +Validate quayConfig.users, organizations, and repositories. +Renders nothing. Fails the release on invalid input. +*/}} +{{- define "quay.config.validate" -}} +{{- $inits := 0 -}} +{{- $supers := 0 -}} +{{- range .Values.quayConfig.users }} +{{- if not .name }} +{{- fail "each quayConfig.users entry needs a name" }} +{{- end }} +{{- if not .passwordProperty }} +{{- fail (printf "user %s needs passwordProperty" .name) }} +{{- end }} +{{- if .initialize }} +{{- $inits = add $inits 1 }} +{{- if not .superuser }} +{{- fail (printf "initialize user %s must be a superuser" .name) }} +{{- end }} +{{- end }} +{{- if .superuser }} +{{- $supers = add $supers 1 }} +{{- end }} +{{- end }} +{{- if ne (int $inits) 1 }} +{{- fail "quayConfig.users must include exactly one user with initialize: true" }} +{{- end }} +{{- if lt (int $supers) 1 }} +{{- fail "quayConfig.users must include at least one superuser" }} +{{- end }} +{{- range .Values.quayConfig.organizations }} +{{- if lt (len .name) 4 }} +{{- fail (printf "organization name %q must be at least 4 characters" .name) }} +{{- end }} +{{- end }} +{{- range .Values.quayConfig.repositories }} +{{- if not (contains "/" .name) }} +{{- fail (printf "repository name %q must be namespace/name" .name) }} +{{- end }} +{{- end }} +{{- end -}} + +{{- define "quay.pod.securityContext" -}} +runAsNonRoot: true +seccompProfile: + type: RuntimeDefault +{{- end -}} + +{{- define "quay.container.securityContext" -}} +allowPrivilegeEscalation: false +runAsNonRoot: true +readOnlyRootFilesystem: true +capabilities: + drop: + - ALL +seccompProfile: + type: RuntimeDefault +{{- end -}} + +{{/* +Managed Quay route name: -quay. +*/}} +{{- define "quay.routeName" -}} +{{- printf "%s-quay" .Values.quay.name -}} +{{- end -}} + +{{/* +ConsoleLink href. consoleLink.href wins when set. +*/}} +{{- define "quay.console.href" -}} +{{- if .Values.consoleLink.href -}} +{{- .Values.consoleLink.href -}} +{{- else -}} +{{- printf "https://%s-%s.apps.%s" (include "quay.routeName" .) .Values.quay.namespace .Values.global.clusterDomain -}} +{{- end -}} +{{- end -}} + +{{/* +Ansible playbook for infra.quay_configuration. Passwords stay in the +mounted Secret and are read with a file lookup. +*/}} +{{- define "quay.config.playbook" -}} +{{- include "quay.config.validate" . -}} +{{- $admin := dict -}} +{{- range .Values.quayConfig.users }} +{{- if .initialize }} +{{- $admin = . }} +{{- end }} +{{- end }} +--- +- name: Configure Red Hat Quay + hosts: localhost + gather_facts: false + vars: + admin_username: {{ $admin.name | quote }} + admin_password: "{{`{{ lookup('ansible.builtin.file', '/quay-config-secrets/`}}{{ $admin.passwordProperty }}{{`') }}`}}" + tasks: + - name: Create the first user when the registry is empty + infra.quay_configuration.quay_first_user: + username: {{ $admin.name | quote }} + email: {{ $admin.email | quote }} + password: "{{`{{ admin_password }}`}}" + quay_host: "{{`{{ quay_host }}`}}" + validate_certs: "{{`{{ validate_certs | bool }}`}}" + register: first_user + failed_when: + - first_user.failed | default(false) + - first_user.msg | default('') | string is not search('non-empty|already', ignorecase=True) +{{- range .Values.quayConfig.users }} +{{- if not .initialize }} + - name: Ensure user {{ .name }} exists + infra.quay_configuration.quay_user: + username: {{ .name | quote }} + email: {{ .email | quote }} + password: "{{`{{ lookup('ansible.builtin.file', '/quay-config-secrets/`}}{{ .passwordProperty }}{{`') }}`}}" + superuser: {{ .superuser }} + state: present + quay_host: "{{`{{ quay_host }}`}}" + quay_username: "{{`{{ admin_username }}`}}" + quay_password: "{{`{{ admin_password }}`}}" + validate_certs: "{{`{{ validate_certs | bool }}`}}" +{{- end }} +{{- end }} +{{- range .Values.quayConfig.organizations }} + - name: Ensure organization {{ .name }} exists + infra.quay_configuration.quay_organization: + name: {{ .name | quote }} + email: {{ .email | quote }} + state: present + quay_host: "{{`{{ quay_host }}`}}" + quay_username: "{{`{{ admin_username }}`}}" + quay_password: "{{`{{ admin_password }}`}}" + validate_certs: "{{`{{ validate_certs | bool }}`}}" +{{- end }} +{{- range .Values.quayConfig.repositories }} + - name: Ensure repository {{ .name }} exists + infra.quay_configuration.quay_repository: + name: {{ .name | quote }} + visibility: {{ .visibility | default "private" | quote }} + state: present + quay_host: "{{`{{ quay_host }}`}}" + quay_username: "{{`{{ admin_username }}`}}" + quay_password: "{{`{{ admin_password }}`}}" + validate_certs: "{{`{{ validate_certs | bool }}`}}" +{{- end }} +{{- end -}} + +{{/* +Pod spec shared by the Quay configuration Job and CronJob. +*/}} +{{- define "quay.config.podSpec" -}} +restartPolicy: Never +serviceAccountName: quay-config +automountServiceAccountToken: true +securityContext: + {{- include "quay.pod.securityContext" . | nindent 2 }} +volumes: + - name: tmp + emptyDir: {} + - name: work + emptyDir: {} + - name: quay-config + configMap: + name: quay-config + defaultMode: 0555 + - name: quay-config-secrets + secret: + secretName: quay-config-credentials +{{- if .Values.configJob.installCollection }} +initContainers: + - name: install-collection + image: {{ .Values.configJob.image | quote }} + imagePullPolicy: {{ .Values.configJob.imagePullPolicy }} + securityContext: + {{- include "quay.container.securityContext" . | nindent 6 }} + resources: + {{- toYaml .Values.configJob.resources | nindent 6 }} + env: + - name: COLLECTIONS_PATH + value: /quay-work/collections + - name: HOME + value: /quay-work + command: + - /bin/bash + - /quay-config/install.sh + volumeMounts: + - name: tmp + mountPath: /tmp + - name: work + mountPath: /quay-work + - name: quay-config + mountPath: /quay-config + readOnly: true +{{- end }} +containers: + - name: configure-quay + image: {{ .Values.configJob.image | quote }} + imagePullPolicy: {{ .Values.configJob.imagePullPolicy }} + securityContext: + {{- include "quay.container.securityContext" . | nindent 6 }} + resources: + {{- toYaml .Values.configJob.resources | nindent 6 }} + env: + - name: QUAY_NAMESPACE + value: {{ .Values.quay.namespace | quote }} + - name: QUAY_ROUTE + value: {{ include "quay.routeName" . | quote }} + - name: INSTALL_COLLECTION + value: {{ .Values.configJob.installCollection | quote }} + - name: VALIDATE_CERTS + value: {{ .Values.configJob.validateCerts | quote }} + - name: COLLECTIONS_PATH + value: /quay-work/collections + - name: HOME_DIR + value: /quay-work + command: + - /bin/bash + - /quay-config/run.sh + volumeMounts: + - name: tmp + mountPath: /tmp + - name: work + mountPath: /quay-work + - name: quay-config + mountPath: /quay-config + readOnly: true + - name: quay-config-secrets + mountPath: /quay-config-secrets + readOnly: true +{{- end -}} diff --git a/templates/console-link.yaml b/templates/console-link.yaml new file mode 100644 index 0000000..0aab399 --- /dev/null +++ b/templates/console-link.yaml @@ -0,0 +1,15 @@ +{{- if .Values.consoleLink.enabled }} +apiVersion: console.openshift.io/v1 +kind: ConsoleLink +metadata: + name: {{ .Values.consoleLink.name }} + annotations: + argocd.argoproj.io/sync-wave: "44" +spec: + applicationMenu: + section: {{ .Values.consoleLink.section | quote }} + imageURL: {{ printf "data:image/png;base64,%s" (.Files.Get "files/quay-icon.png" | b64enc) | quote }} + href: {{ include "quay.console.href" . | quote }} + text: {{ .Values.consoleLink.text | quote }} + location: ApplicationMenu +{{- end }} diff --git a/templates/mcg-backingstore.yaml b/templates/mcg-backingstore.yaml new file mode 100644 index 0000000..55446fe --- /dev/null +++ b/templates/mcg-backingstore.yaml @@ -0,0 +1,21 @@ +{{- if eq .Values.objectStorage.mode "mcg" }} +apiVersion: noobaa.io/v1alpha1 +kind: BackingStore +metadata: + name: {{ .Values.objectStorage.mcg.backingStore.name }} + namespace: {{ .Values.objectStorage.mcg.namespace }} + annotations: + argocd.argoproj.io/sync-wave: "32" +spec: + type: pv-pool + pvPool: + numVolumes: {{ .Values.objectStorage.mcg.pvPool.numVolumes }} + resources: + requests: + cpu: {{ .Values.objectStorage.mcg.pvPool.resources.requests.cpu | quote }} + memory: {{ .Values.objectStorage.mcg.pvPool.resources.requests.memory | quote }} + storage: {{ .Values.objectStorage.mcg.pvPool.resources.requests.storage }} + limits: + cpu: {{ .Values.objectStorage.mcg.pvPool.resources.limits.cpu | quote }} + memory: {{ .Values.objectStorage.mcg.pvPool.resources.limits.memory | quote }} +{{- end }} diff --git a/templates/mcg-bucketclass.yaml b/templates/mcg-bucketclass.yaml new file mode 100644 index 0000000..2f2eaa0 --- /dev/null +++ b/templates/mcg-bucketclass.yaml @@ -0,0 +1,18 @@ +{{- if eq .Values.objectStorage.mode "mcg" }} +apiVersion: noobaa.io/v1alpha1 +kind: BucketClass +metadata: + name: {{ .Values.objectStorage.mcg.bucketClass.name }} + namespace: {{ .Values.objectStorage.mcg.namespace }} + annotations: + argocd.argoproj.io/sync-wave: "34" +spec: + placementPolicy: + tiers: + {{- range .Values.objectStorage.mcg.bucketClass.placement.tiers }} + - backingStores: + {{- range .backingStores }} + - {{ . }} + {{- end }} + {{- end }} +{{- end }} diff --git a/templates/mcg-noobaa.yaml b/templates/mcg-noobaa.yaml new file mode 100644 index 0000000..ecf4c60 --- /dev/null +++ b/templates/mcg-noobaa.yaml @@ -0,0 +1,18 @@ +{{- if eq .Values.objectStorage.mode "mcg" }} +apiVersion: noobaa.io/v1alpha1 +kind: NooBaa +metadata: + name: {{ .Values.objectStorage.mcg.system.name }} + namespace: {{ .Values.objectStorage.mcg.namespace }} + annotations: + argocd.argoproj.io/sync-wave: "33" +spec: + tolerations: + - key: "node.ocs.openshift.io/storage" + operator: "Equal" + value: "true" + effect: "NoSchedule" + dbVolumeResources: + requests: + storage: {{ .Values.objectStorage.mcg.dbSize }} +{{- end }} diff --git a/templates/object-bucket-claim.yaml b/templates/object-bucket-claim.yaml index 22f79b1..c7ce5a9 100644 --- a/templates/object-bucket-claim.yaml +++ b/templates/object-bucket-claim.yaml @@ -1,11 +1,13 @@ ---- +{{- if not (or (eq .Values.objectStorage.mode "mcg") (eq .Values.objectStorage.mode "odf")) }} +{{- fail (printf "objectStorage.mode must be mcg or odf, got %q" .Values.objectStorage.mode) }} +{{- end }} apiVersion: objectbucket.io/v1alpha1 kind: ObjectBucketClaim metadata: name: {{ .Values.objectStorage.objectBucketClaim.name }} namespace: {{ .Values.quay.namespace }} annotations: - argocd.argoproj.io/sync-wave: "36" # Create OBC after NooBaa system is ready + argocd.argoproj.io/sync-wave: "36" spec: generateBucketName: {{ .Values.objectStorage.objectBucketClaim.bucketName }} storageClassName: {{ .Values.objectStorage.objectBucketClaim.storageClass }} diff --git a/templates/quay-config-bundle-secret.yaml b/templates/quay-config-bundle-secret.yaml index dd5adc7..6bdd4fa 100644 --- a/templates/quay-config-bundle-secret.yaml +++ b/templates/quay-config-bundle-secret.yaml @@ -1,10 +1,11 @@ +{{- include "quay.config.validate" . -}} apiVersion: v1 kind: Secret metadata: name: {{ .Values.quay.configBundleSecret.name }} namespace: {{ .Values.quay.namespace }} annotations: - argocd.argoproj.io/sync-wave: "38" # Layer 1: Create base config secret (template) + argocd.argoproj.io/sync-wave: "38" type: Opaque stringData: config.yaml: | @@ -12,7 +13,11 @@ stringData: FEATURE_GENERAL_OCI_SUPPORT: true BROWSER_API_CALLS_XHR_ONLY: false SUPER_USERS: - - {{ .Values.quay.setup.admin.name }} + {{- range .Values.quayConfig.users }} + {{- if .superuser }} + - {{ .name }} + {{- end }} + {{- end }} FEATURE_USER_CREATION: true ALLOW_PULLS_WITHOUT_STRICT_LOGGING: false AUTHENTICATION_TYPE: Database @@ -28,14 +33,14 @@ stringData: CREATE_NAMESPACE_ON_PUSH: true # Proxy all storage traffic through Quay instead of direct client access FEATURE_PROXY_STORAGE: true - # NooBaa Multicloud Object Gateway (MCG) S3-compatible storage configuration - # This is a template - actual secret with real credentials is created by quay-s3-credentials-setup job + # Template only. quay-s3-credentials-setup replaces the placeholders + # from the bound ObjectBucketClaim. DISTRIBUTED_STORAGE_CONFIG: default: - RHOCSStorage - - hostname: s3.openshift-storage.svc.cluster.local - port: 443 - is_secure: true + - hostname: PLACEHOLDER_BUCKET_HOST + port: PLACEHOLDER_BUCKET_PORT + is_secure: PLACEHOLDER_IS_SECURE storage_path: /datastorage/registry access_key: PLACEHOLDER_ACCESS_KEY secret_key: PLACEHOLDER_SECRET_KEY diff --git a/templates/quay-config-configmap.yaml b/templates/quay-config-configmap.yaml new file mode 100644 index 0000000..e761a76 --- /dev/null +++ b/templates/quay-config-configmap.yaml @@ -0,0 +1,21 @@ +{{- if .Values.configJob.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: quay-config + namespace: {{ .Values.quay.namespace }} + annotations: + argocd.argoproj.io/sync-wave: "40" +data: + requirements.yml: | + --- + collections: + - name: infra.quay_configuration + version: {{ .Values.configJob.collectionVersion | quote }} + playbook.yml: | +{{- include "quay.config.playbook" . | trim | nindent 4 }} + install.sh: | +{{- .Files.Get "files/quay-config-install.sh" | trim | nindent 4 }} + run.sh: | +{{- .Files.Get "files/quay-config-run.sh" | trim | nindent 4 }} +{{- end }} diff --git a/templates/quay-config-cronjob.yaml b/templates/quay-config-cronjob.yaml new file mode 100644 index 0000000..e53662a --- /dev/null +++ b/templates/quay-config-cronjob.yaml @@ -0,0 +1,21 @@ +{{- if .Values.configJob.enabled }} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: quay-config + namespace: {{ .Values.quay.namespace }} + annotations: + argocd.argoproj.io/sync-wave: "43" +spec: + schedule: {{ .Values.configJob.schedule | quote }} + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: {{ .Values.configJob.successfulJobsHistoryLimit }} + failedJobsHistoryLimit: {{ .Values.configJob.failedJobsHistoryLimit }} + jobTemplate: + spec: + backoffLimit: {{ .Values.configJob.backoffLimit }} + activeDeadlineSeconds: {{ .Values.configJob.activeDeadlineSeconds }} + template: + spec: + {{- include "quay.config.podSpec" . | nindent 10 }} +{{- end }} diff --git a/templates/quay-config-externalsecret.yaml b/templates/quay-config-externalsecret.yaml new file mode 100644 index 0000000..825c271 --- /dev/null +++ b/templates/quay-config-externalsecret.yaml @@ -0,0 +1,24 @@ +{{- if .Values.configJob.enabled }} +{{- include "quay.config.validate" . -}} +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: quay-config-credentials + namespace: {{ .Values.quay.namespace }} + annotations: + argocd.argoproj.io/sync-wave: "40" +spec: + refreshInterval: 15s + secretStoreRef: + name: {{ .Values.secretStore.name }} + kind: {{ .Values.secretStore.kind }} + target: + name: quay-config-credentials + data: + {{- range .Values.quayConfig.users }} + - secretKey: {{ .passwordProperty }} + remoteRef: + key: {{ $.Values.quayConfig.credentials.key }} + property: {{ .passwordProperty }} + {{- end }} +{{- end }} diff --git a/templates/quay-config-job.yaml b/templates/quay-config-job.yaml new file mode 100644 index 0000000..5d13df3 --- /dev/null +++ b/templates/quay-config-job.yaml @@ -0,0 +1,15 @@ +{{- if .Values.configJob.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: quay-config-bootstrap + namespace: {{ .Values.quay.namespace }} + annotations: + argocd.argoproj.io/sync-wave: "42" +spec: + backoffLimit: {{ .Values.configJob.backoffLimit }} + activeDeadlineSeconds: {{ .Values.configJob.activeDeadlineSeconds }} + template: + spec: + {{- include "quay.config.podSpec" . | nindent 6 }} +{{- end }} diff --git a/templates/quay-config-rbac.yaml b/templates/quay-config-rbac.yaml new file mode 100644 index 0000000..091bd51 --- /dev/null +++ b/templates/quay-config-rbac.yaml @@ -0,0 +1,37 @@ +{{- if .Values.configJob.enabled }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: quay-config + namespace: {{ .Values.quay.namespace }} + annotations: + argocd.argoproj.io/sync-wave: "40" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: quay-config + namespace: {{ .Values.quay.namespace }} + annotations: + argocd.argoproj.io/sync-wave: "40" +rules: + - apiGroups: ["route.openshift.io"] + resources: ["routes"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: quay-config + namespace: {{ .Values.quay.namespace }} + annotations: + argocd.argoproj.io/sync-wave: "40" +subjects: + - kind: ServiceAccount + name: quay-config + namespace: {{ .Values.quay.namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: quay-config +{{- end }} diff --git a/templates/quay-registry.yaml b/templates/quay-registry.yaml index c58c5f6..97ccd9c 100644 --- a/templates/quay-registry.yaml +++ b/templates/quay-registry.yaml @@ -1,7 +1,7 @@ apiVersion: quay.redhat.com/v1 kind: QuayRegistry metadata: - name: quay-registry + name: {{ .Values.quay.name }} namespace: {{ .Values.quay.namespace | default "quay-enterprise" }} annotations: argocd.argoproj.io/sync-wave: "41" # Layer 1: Deploy Quay Registry @@ -34,5 +34,5 @@ spec: managed: true overrides: volumeSize: {{ .Values.quay.storage.clairpostgres.size }} - # Use the secret created by the S3 job (with real credentials), not the Git-managed template - configBundleSecret: quay-config-with-s3 + # Secret written by the S3 job, not the Git-managed template. + configBundleSecret: {{ .Values.quay.configBundleSecret.s3Name }} diff --git a/templates/quay-s3-credentials-job.yaml b/templates/quay-s3-credentials-job.yaml index 1c0cb83..681ee5b 100644 --- a/templates/quay-s3-credentials-job.yaml +++ b/templates/quay-s3-credentials-job.yaml @@ -1,3 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: quay-s3-credentials-setup + namespace: {{ .Values.quay.namespace }} + annotations: + argocd.argoproj.io/sync-wave: "38" +data: + setup.sh: | +{{- .Files.Get "files/s3-credentials-setup.sh" | trim | nindent 4 }} --- apiVersion: batch/v1 kind: Job @@ -5,108 +15,47 @@ metadata: name: quay-s3-credentials-setup namespace: {{ .Values.quay.namespace }} annotations: - argocd.argoproj.io/sync-wave: "39" # Layer 1: Setup S3 credentials + argocd.argoproj.io/sync-wave: "39" spec: + backoffLimit: 5 template: spec: securityContext: - runAsNonRoot: true - seccompProfile: - type: RuntimeDefault + {{- include "quay.pod.securityContext" . | nindent 8 }} serviceAccountName: quay-s3-setup - # Job needs SA token for oc CLI (CKV_K8S_38 skipped via checkov config) + # Job needs an SA token for oc (CKV_K8S_38 skipped via checkov config) automountServiceAccountToken: true containers: - - name: setup-s3-credentials - image: {{ .Values.job.image | default "image-registry.openshift-image-registry.svc:5000/openshift/cli" }} - imagePullPolicy: Always - securityContext: - allowPrivilegeEscalation: false - runAsNonRoot: true - readOnlyRootFilesystem: true - capabilities: - drop: - - ALL - seccompProfile: - type: RuntimeDefault - resources: - {{- with .Values.job.resources }} - {{- toYaml . | nindent 10 }} - {{- else }} - requests: - cpu: 50m - memory: 128Mi - limits: - cpu: 500m - memory: 256Mi - {{- end }} - volumeMounts: - - name: tmp - mountPath: /tmp - command: - - /bin/bash - - -c - - | - set -e - echo "Setting up S3 credentials for Quay from NooBaa MCG ObjectBucketClaim..." - - echo "Using oc for Kubernetes API access..." - - # Check if ObjectBucketClaim exists and is bound - echo "Checking ObjectBucketClaim quay-bucket status..." - oc get objectbucketclaim quay-bucket -n {{ .Values.quay.namespace }} - - # Wait for ObjectBucketClaim to be in Bound state - echo "Waiting for ObjectBucketClaim quay-bucket to be Bound (timeout: 10 minutes)..." - oc wait --for=jsonpath='{.status.phase}'=Bound objectbucketclaim/quay-bucket -n {{ .Values.quay.namespace }} --timeout=600s || { - echo "ERROR: ObjectBucketClaim failed to reach Bound state within timeout" - oc describe objectbucketclaim quay-bucket -n {{ .Values.quay.namespace }} - exit 1 - } - - # Use the actual secret and configmap names (not the objectBucketName from spec) - CONFIG_MAP="quay-bucket" - SECRET_NAME="quay-bucket" - - echo "ConfigMap: $CONFIG_MAP" - echo "Secret: $SECRET_NAME" - - # Extract S3 credentials from Quay namespace - ACCESS_KEY=$(oc get secret $SECRET_NAME -n {{ .Values.quay.namespace }} -o jsonpath='{.data.AWS_ACCESS_KEY_ID}' | base64 -d) - SECRET_KEY=$(oc get secret $SECRET_NAME -n {{ .Values.quay.namespace }} -o jsonpath='{.data.AWS_SECRET_ACCESS_KEY}' | base64 -d) - BUCKET_NAME=$(oc get configmap $CONFIG_MAP -n {{ .Values.quay.namespace }} -o jsonpath='{.data.BUCKET_NAME}') - S3_ENDPOINT=$(oc get configmap $CONFIG_MAP -n {{ .Values.quay.namespace }} -o jsonpath='{.data.BUCKET_HOST}') - - echo "Retrieved S3 credentials successfully" - echo "Bucket: $BUCKET_NAME" - echo "Endpoint: $S3_ENDPOINT" - - # With FEATURE_PROXY_STORAGE enabled, all traffic goes through Quay proxy - echo "Using RHOCSStorage with proxy mode - clients will access storage through Quay" - - # Get the template config secret (with placeholders) - oc get secret {{ .Values.quay.configBundleSecret.name }} -n {{ .Values.quay.namespace }} -o jsonpath='{.data.config\.yaml}' | base64 -d > /tmp/config.yaml - - # Replace placeholders with actual values using a different delimiter to handle special characters - sed -i "s|PLACEHOLDER_ACCESS_KEY|$ACCESS_KEY|g" /tmp/config.yaml - sed -i "s|PLACEHOLDER_SECRET_KEY|$SECRET_KEY|g" /tmp/config.yaml - sed -i "s|PLACEHOLDER_BUCKET_NAME|$BUCKET_NAME|g" /tmp/config.yaml - - # Note: With FEATURE_PROXY_STORAGE enabled, we keep internal hostname since all traffic goes through Quay proxy - - echo "Updated Quay configuration to use RHOCSStorage with proxy mode" - - # Create a NEW secret with the actual credentials (not modifying the Git-managed one) - echo "Creating quay-config-with-s3 secret with real credentials..." - oc create secret generic quay-config-with-s3 \ - --from-file=config.yaml=/tmp/config.yaml \ - -n {{ .Values.quay.namespace }} \ - --dry-run=client -o yaml | oc apply -f - - - echo "Quay S3 credentials setup completed successfully" - echo "Created quay-config-with-s3 secret with real S3 credentials" + - name: setup-s3-credentials + image: {{ .Values.job.image | quote }} + imagePullPolicy: Always + securityContext: + {{- include "quay.container.securityContext" . | nindent 12 }} + resources: + {{- toYaml .Values.job.resources | nindent 12 }} + env: + - name: QUAY_NAMESPACE + value: {{ .Values.quay.namespace | quote }} + - name: OBC_NAME + value: {{ .Values.objectStorage.objectBucketClaim.name | quote }} + - name: CONFIG_SECRET + value: {{ .Values.quay.configBundleSecret.name | quote }} + - name: OUTPUT_SECRET + value: {{ .Values.quay.configBundleSecret.s3Name | quote }} + command: + - /bin/bash + - /scripts/setup.sh + volumeMounts: + - name: tmp + mountPath: /tmp + - name: script + mountPath: /scripts + readOnly: true volumes: - - name: tmp - emptyDir: {} + - name: tmp + emptyDir: {} + - name: script + configMap: + name: quay-s3-credentials-setup + defaultMode: 0555 restartPolicy: OnFailure - backoffLimit: 5 diff --git a/templates/quay-s3-setup-serviceaccount.yaml b/templates/quay-s3-setup-serviceaccount.yaml index 7b25701..25eeffb 100644 --- a/templates/quay-s3-setup-serviceaccount.yaml +++ b/templates/quay-s3-setup-serviceaccount.yaml @@ -21,8 +21,10 @@ rules: - apiGroups: [""] resources: ["secrets"] verbs: ["get", "create", "patch", "update"] - resourceNames: ["quay-init-config-bundle-secret", "quay-config-with-s3"] -# Need to create quay-config-with-s3 secret without resourceName restriction + resourceNames: + - {{ .Values.quay.configBundleSecret.name | quote }} + - {{ .Values.quay.configBundleSecret.s3Name | quote }} +# Creating the rendered secret is a separate rule so resourceNames does not block it. - apiGroups: [""] resources: ["secrets"] verbs: ["create"] diff --git a/tests/config_bundle_test.yaml b/tests/config_bundle_test.yaml new file mode 100644 index 0000000..6bc8708 --- /dev/null +++ b/tests/config_bundle_test.yaml @@ -0,0 +1,18 @@ +suite: Quay config bundle +templates: + - templates/quay-config-bundle-secret.yaml +tests: + - it: leaves storage placeholders for the credentials job + asserts: + - matchRegex: + path: stringData["config.yaml"] + pattern: PLACEHOLDER_BUCKET_HOST + - matchRegex: + path: stringData["config.yaml"] + pattern: PLACEHOLDER_BUCKET_PORT + - matchRegex: + path: stringData["config.yaml"] + pattern: PLACEHOLDER_IS_SECURE + - matchRegex: + path: stringData["config.yaml"] + pattern: "- quayadmin" diff --git a/tests/console_link_test.yaml b/tests/console_link_test.yaml new file mode 100644 index 0000000..7bbef27 --- /dev/null +++ b/tests/console_link_test.yaml @@ -0,0 +1,51 @@ +suite: Console link +templates: + - templates/console-link.yaml +tests: + - it: links the Quay route and embeds the Quay icon + asserts: + - isKind: + of: ConsoleLink + - equal: + path: spec.text + value: Red Hat Quay + - equal: + path: spec.location + value: ApplicationMenu + - equal: + path: spec.applicationMenu.section + value: Red Hat applications + - equal: + path: spec.href + value: https://quay-registry-quay-quay-enterprise.apps.example.com + - matchRegex: + path: spec.applicationMenu.imageURL + pattern: "^data:image/png;base64,iVBORw0KGgo" + - it: honors an explicit href + set: + global: + clusterDomain: cluster.example.com + consoleLink: + href: https://quay.example.com + asserts: + - equal: + path: spec.href + value: https://quay.example.com + - it: builds the href from the cluster domain + set: + quay: + name: q + namespace: n + global: + clusterDomain: c.example.com + asserts: + - equal: + path: spec.href + value: https://q-quay-n.apps.c.example.com + - it: can be disabled + set: + consoleLink: + enabled: false + asserts: + - hasDocuments: + count: 0 diff --git a/tests/object_bucket_claim_test.yaml b/tests/object_bucket_claim_test.yaml new file mode 100644 index 0000000..16ca872 --- /dev/null +++ b/tests/object_bucket_claim_test.yaml @@ -0,0 +1,26 @@ +suite: ObjectBucketClaim +templates: + - templates/object-bucket-claim.yaml +tests: + - it: claims the NooBaa storage class by default + asserts: + - isKind: + of: ObjectBucketClaim + - equal: + path: spec.storageClassName + value: openshift-storage.noobaa.io + - equal: + path: spec.generateBucketName + value: quay-datastore + - equal: + path: metadata.name + value: quay-bucket + - it: honors a Ceph RGW storage class override + set: + objectStorage: + objectBucketClaim: + storageClass: ocs-storagecluster-ceph-rgw + asserts: + - equal: + path: spec.storageClassName + value: ocs-storagecluster-ceph-rgw diff --git a/tests/object_storage_test.yaml b/tests/object_storage_test.yaml new file mode 100644 index 0000000..91b585f --- /dev/null +++ b/tests/object_storage_test.yaml @@ -0,0 +1,60 @@ +suite: Object storage backends +tests: + - it: renders NooBaa in openshift-storage + templates: + - templates/mcg-noobaa.yaml + asserts: + - hasDocuments: + count: 1 + - isKind: + of: NooBaa + - equal: + path: metadata.name + value: noobaa + - equal: + path: metadata.namespace + value: openshift-storage + - equal: + path: metadata.annotations["argocd.argoproj.io/sync-wave"] + value: "33" + - it: renders the pv-pool backing store before NooBaa + templates: + - templates/mcg-backingstore.yaml + asserts: + - isKind: + of: BackingStore + - equal: + path: metadata.name + value: noobaa-default-backing-store + - equal: + path: metadata.namespace + value: openshift-storage + - equal: + path: metadata.annotations["argocd.argoproj.io/sync-wave"] + value: "32" + - equal: + path: spec.pvPool.numVolumes + value: 1 + - it: points the bucket class at the backing store + templates: + - templates/mcg-bucketclass.yaml + asserts: + - isKind: + of: BucketClass + - equal: + path: spec.placementPolicy.tiers[0].backingStores[0] + value: noobaa-default-backing-store + - equal: + path: metadata.annotations["argocd.argoproj.io/sync-wave"] + value: "34" + - it: does not render gateway resources for an existing ODF cluster + templates: + - templates/mcg-noobaa.yaml + - templates/mcg-backingstore.yaml + - templates/mcg-bucketclass.yaml + set: + objectStorage: + mode: odf + asserts: + - hasDocuments: + count: 0 diff --git a/tests/quay_config_cronjob_test.yaml b/tests/quay_config_cronjob_test.yaml new file mode 100644 index 0000000..f760b1c --- /dev/null +++ b/tests/quay_config_cronjob_test.yaml @@ -0,0 +1,24 @@ +suite: Ansible configuration cronjob +templates: + - templates/quay-config-cronjob.yaml +tests: + - it: reconciles on a cron schedule + asserts: + - isKind: + of: CronJob + - equal: + path: spec.schedule + value: "*/30 * * * *" + - equal: + path: spec.concurrencyPolicy + value: Forbid + - equal: + path: spec.jobTemplate.spec.template.spec.containers[0].name + value: configure-quay + - it: does not render when configuration is disabled + set: + configJob: + enabled: false + asserts: + - hasDocuments: + count: 0 diff --git a/tests/quay_config_job_test.yaml b/tests/quay_config_job_test.yaml new file mode 100644 index 0000000..03b2926 --- /dev/null +++ b/tests/quay_config_job_test.yaml @@ -0,0 +1,37 @@ +suite: Ansible configuration job +templates: + - templates/quay-config-job.yaml +tests: + - it: bootstraps configuration after the registry + asserts: + - isKind: + of: Job + - equal: + path: metadata.name + value: quay-config-bootstrap + - equal: + path: metadata.annotations["argocd.argoproj.io/sync-wave"] + value: "42" + - equal: + path: spec.template.spec.serviceAccountName + value: quay-config + - equal: + path: spec.template.spec.initContainers[0].command[1] + value: /quay-config/install.sh + - equal: + path: spec.template.spec.containers[0].command[1] + value: /quay-config/run.sh + - it: skips collection install when the image already provides it + set: + configJob: + installCollection: false + asserts: + - notExists: + path: spec.template.spec.initContainers + - it: does not render when configuration is disabled + set: + configJob: + enabled: false + asserts: + - hasDocuments: + count: 0 diff --git a/tests/quay_config_playbook_test.yaml b/tests/quay_config_playbook_test.yaml new file mode 100644 index 0000000..d25c0d0 --- /dev/null +++ b/tests/quay_config_playbook_test.yaml @@ -0,0 +1,21 @@ +suite: Ansible configuration playbook +templates: + - templates/quay-config-configmap.yaml +tests: + - it: renders a playbook for the collection + asserts: + - matchRegex: + path: data["requirements.yml"] + pattern: infra.quay_configuration + - matchRegex: + path: data["playbook.yml"] + pattern: "quay_first_user:" + - matchRegex: + path: data["playbook.yml"] + pattern: "quay_organization:" + - matchRegex: + path: data["playbook.yml"] + pattern: "devel/example" + - matchRegex: + path: data["install.sh"] + pattern: "ansible-galaxy collection install" diff --git a/tests/quay_config_secret_test.yaml b/tests/quay_config_secret_test.yaml new file mode 100644 index 0000000..be7063c --- /dev/null +++ b/tests/quay_config_secret_test.yaml @@ -0,0 +1,36 @@ +suite: Ansible configuration external secret +templates: + - templates/quay-config-externalsecret.yaml +tests: + - it: projects user passwords from the secret store + asserts: + - isKind: + of: ExternalSecret + - equal: + path: spec.secretStoreRef.name + value: vault-backend + - equal: + path: spec.secretStoreRef.kind + value: ClusterSecretStore + - equal: + path: spec.target.name + value: quay-config-credentials + - equal: + path: spec.data[0].secretKey + value: quay-admin-password + - equal: + path: spec.data[0].remoteRef.key + value: secret/data/hub/infra/quay/quay-users + - equal: + path: spec.data[0].remoteRef.property + value: quay-admin-password + - equal: + path: spec.data[1].secretKey + value: quay-user-password + - it: does not render when configuration is disabled + set: + configJob: + enabled: false + asserts: + - hasDocuments: + count: 0 diff --git a/tests/quay_registry_test.yaml b/tests/quay_registry_test.yaml new file mode 100644 index 0000000..c2f3d0f --- /dev/null +++ b/tests/quay_registry_test.yaml @@ -0,0 +1,15 @@ +suite: QuayRegistry +templates: + - templates/quay-registry.yaml +tests: + - it: points QuayRegistry at the rendered secret + asserts: + - equal: + path: spec.configBundleSecret + value: quay-config-with-s3 + - equal: + path: spec.components[6].kind + value: objectstorage + - equal: + path: spec.components[6].managed + value: false diff --git a/tests/s3_credentials_job_test.yaml b/tests/s3_credentials_job_test.yaml new file mode 100644 index 0000000..c320bc6 --- /dev/null +++ b/tests/s3_credentials_job_test.yaml @@ -0,0 +1,21 @@ +suite: S3 credentials job +templates: + - templates/quay-s3-credentials-job.yaml +tests: + - it: passes claim and secret names into the credentials job + documentIndex: 1 + asserts: + - isKind: + of: Job + - equal: + path: spec.template.spec.containers[0].env[1].name + value: OBC_NAME + - equal: + path: spec.template.spec.containers[0].env[1].value + value: quay-bucket + - equal: + path: spec.template.spec.containers[0].env[3].value + value: quay-config-with-s3 + - equal: + path: spec.template.spec.containers[0].command[1] + value: /scripts/setup.sh diff --git a/values.yaml b/values.yaml index c4419c6..0be8c67 100644 --- a/values.yaml +++ b/values.yaml @@ -1,25 +1,32 @@ +global: + # -- OpenShift cluster base domain. Used in the console link when + # consoleLink.href is empty. The host is apps. plus this domain. + clusterDomain: example.com + quay: + # -- Namespace for the Quay registry and its configuration jobs. namespace: quay-enterprise + # -- Name of the QuayRegistry resource. The managed route is this name + # with -quay appended. + name: quay-registry configBundleSecret: deploy: true + # -- Template secret. The S3 job copies it and fills storage placeholders. name: quay-init-config-bundle-secret - setup: - admin: - name: quayadmin - email: quayadmin@example.com - user: - name: developer1 - email: developer1@myorg.com + # -- Secret QuayRegistry reads after the S3 job fills credentials. + s3Name: quay-config-with-s3 storage: postgres: - size: 50Gi # Default and minimum size is 50 Gi + # -- Persistent volume size for the Quay PostgreSQL database. + size: 50Gi clairpostgres: - size: 50Gi # Default and minimum size is 50 Gi + # -- Persistent volume size for the Clair PostgreSQL database. + size: 50Gi job: - # Uses OpenShift built-in cli ImageStream; auto-updates with the cluster - # Override with e.g. registry.redhat.io/openshift4/ose-cli-rhel9:v4.20 - # if the internal image registry is not available + # -- Image for the S3 credentials Job. Uses the OpenShift cli ImageStream, + # which tracks the cluster version. Override when the internal + # registry is unavailable, for example ose-cli-rhel9:v4.20. image: image-registry.openshift-image-registry.svc:5000/openshift/cli resources: requests: @@ -29,15 +36,120 @@ job: cpu: 500m memory: 256Mi -quay_config: - org: - name: devel - email: devel@myorg.com - repo: example - -# Object Storage configuration - using NooBaa MCG +# Object storage. mode mcg deploys the standalone Multicloud Object Gateway. +# mode odf uses the NooBaa gateway that a StorageCluster already created. objectStorage: + # -- mcg deploys the standalone Multicloud Object Gateway. odf consumes + # an existing OpenShift Data Foundation StorageCluster. + mode: mcg + mcg: + # -- Namespace of the NooBaa system. Must match the ODF operator namespace. + namespace: openshift-storage + system: + # -- Name of the NooBaa custom resource. + name: noobaa + bucketClass: + # -- BucketClass used by the default NooBaa storage class. + name: noobaa-default-bucket-class + placement: + tiers: + - backingStores: + - noobaa-default-backing-store + backingStore: + # -- BackingStore that holds the gateway's persistent volumes. + name: noobaa-default-backing-store + # -- Size of the NooBaa PostgreSQL volume. + dbSize: 50Gi + pvPool: + # -- Number of persistent volumes in the backing store pool. + numVolumes: 1 + resources: + requests: + cpu: 800m + memory: 800Mi + storage: 50Gi + limits: + cpu: "1" + memory: 4Gi objectBucketClaim: + # -- ObjectBucketClaim name. The bound ConfigMap and Secret use this name. name: quay-bucket + # -- Prefix passed to generateBucketName. bucketName: quay-datastore + # -- StorageClass for the claim. Override for Ceph RGW, for example + # ocs-storagecluster-ceph-rgw. storageClass: openshift-storage.noobaa.io + +secretStore: + # -- SecretStore or ClusterSecretStore that holds Quay user passwords. + name: vault-backend + # -- Kind of secretStore.name. + kind: ClusterSecretStore + +quayConfig: + credentials: + # -- Vault (or other backend) path extracted into quay-config-credentials. + key: secret/data/hub/infra/quay/quay-users + # Exactly one user must set initialize, and that user must be a superuser. + # Organization names must be at least four characters. Repository names + # use the namespace/name form. + users: + - name: quayadmin + email: quayadmin@example.com + # -- Property on credentials.key projected into the credentials Secret. + passwordProperty: quay-admin-password + superuser: true + initialize: true + - name: developer1 + email: developer1@myorg.com + passwordProperty: quay-user-password + superuser: false + initialize: false + organizations: + - name: devel + email: devel@myorg.com + repositories: + - name: devel/example + visibility: private + +consoleLink: + # -- Create an ApplicationMenu ConsoleLink for the Quay route. + enabled: true + # -- ConsoleLink metadata.name. + name: quay + # -- Menu text. + text: Red Hat Quay + # -- Application menu section. + section: Red Hat applications + # -- Override the computed route URL. Empty builds the managed route + # from quay.name, quay.namespace, and global.clusterDomain. + href: "" + +configJob: + # -- Run the bootstrap Job and the reconciling CronJob. + enabled: true + # -- Image with ansible-core, ansible-galaxy, oc, and cURL. + image: quay.io/hybridcloudpatterns/imperative-container:v1 + imagePullPolicy: Always + # -- Cron schedule for re-applying Quay configuration. + schedule: "*/30 * * * *" + # -- Job activeDeadlineSeconds for the bootstrap Job and CronJob pods. + activeDeadlineSeconds: 1800 + backoffLimit: 5 + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 1 + # -- Install infra.quay_configuration into an emptyDir before the playbook. + # Set false when configJob.image already contains the collection. + installCollection: true + # -- infra.quay_configuration version passed to ansible-galaxy. + collectionVersion: "2.8.1" + # -- Verify the Quay route TLS certificate. In-cluster routes often use + # a private CA, so the default is false. + validateCerts: false + resources: + requests: + cpu: 50m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi From e9b240ce7708c5e7788dcbfac6d634acc5fdc38f Mon Sep 17 00:00:00 2001 From: Martin Jackson Date: Fri, 2 Oct 2026 11:50:16 -0500 Subject: [PATCH 2/9] Update defaults --- README.md | 151 +++++++++++++++++++++++++--------------------------- values.yaml | 4 +- 2 files changed, 74 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index f00f69f..8929286 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,11 @@ # quay - ![Version: 0.2.0](https://img.shields.io/badge/Version-0.2.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 3.9](https://img.shields.io/badge/AppVersion-3.9-informational?style=flat-square) - - Red Hat Quay Registry Resources - This chart is used to serve as the template for Validated Patterns Charts @@ -31,85 +27,82 @@ This chart is used to serve as the template for Validated Patterns Charts ## Maintainers -| Name | Email | Url | -| ---------------------------------- | ---------------------------- | --- | -| Zero Trust Validated Patterns Team | | | +| Name | Email | Url | +| ---- | ------ | --- | +| Zero Trust Validated Patterns Team | | | - ## Values -| Key | Type | Default | Description | -| ----------------------------------------------------------------- | ------ | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| configJob.activeDeadlineSeconds | int | `1800` | Job activeDeadlineSeconds for the bootstrap Job and CronJob pods. | -| configJob.backoffLimit | int | `5` | | -| configJob.collectionVersion | string | `"2.8.1"` | infra.quay_configuration version passed to ansible-galaxy. | -| configJob.enabled | bool | `true` | Run the bootstrap Job and the reconciling CronJob. | -| configJob.failedJobsHistoryLimit | int | `1` | | -| configJob.image | string | `"quay.io/hybridcloudpatterns/imperative-container:v1"` | Image with ansible-core, ansible-galaxy, oc, and cURL. | -| configJob.imagePullPolicy | string | `"Always"` | | -| configJob.installCollection | bool | `true` | Install infra.quay_configuration into an emptyDir before the playbook. Set false when configJob.image already contains the collection. | -| configJob.resources.limits.cpu | string | `"500m"` | | -| configJob.resources.limits.memory | string | `"512Mi"` | | -| configJob.resources.requests.cpu | string | `"50m"` | | -| configJob.resources.requests.memory | string | `"256Mi"` | | -| configJob.schedule | string | `"*/30 * * * *"` | Cron schedule for re-applying Quay configuration. | -| configJob.successfulJobsHistoryLimit | int | `1` | | -| configJob.validateCerts | bool | `false` | Verify the Quay route TLS certificate. In-cluster routes often use a private CA, so the default is false. | -| consoleLink.enabled | bool | `true` | Create an ApplicationMenu ConsoleLink for the Quay route. | -| consoleLink.href | string | `""` | Override the computed route URL. Empty builds the managed route from quay.name, quay.namespace, and global.clusterDomain. | -| consoleLink.name | string | `"quay"` | ConsoleLink metadata.name. | -| consoleLink.section | string | `"Red Hat applications"` | Application menu section. | -| consoleLink.text | string | `"Red Hat Quay"` | Menu text. | -| global.clusterDomain | string | `"example.com"` | OpenShift cluster base domain. Used in the console link when consoleLink.href is empty. The host is apps. plus this domain. | -| job.image | string | `"image-registry.openshift-image-registry.svc:5000/openshift/cli"` | Image for the S3 credentials Job. Uses the OpenShift cli ImageStream, which tracks the cluster version. Override when the internal registry is unavailable, for example ose-cli-rhel9:v4.20. | -| job.resources.limits.cpu | string | `"500m"` | | -| job.resources.limits.memory | string | `"256Mi"` | | -| job.resources.requests.cpu | string | `"50m"` | | -| job.resources.requests.memory | string | `"128Mi"` | | -| objectStorage.mcg.backingStore.name | string | `"noobaa-default-backing-store"` | BackingStore that holds the gateway's persistent volumes. | -| objectStorage.mcg.bucketClass.name | string | `"noobaa-default-bucket-class"` | BucketClass used by the default NooBaa storage class. | -| objectStorage.mcg.bucketClass.placement.tiers[0].backingStores[0] | string | `"noobaa-default-backing-store"` | | -| objectStorage.mcg.dbSize | string | `"50Gi"` | Size of the NooBaa PostgreSQL volume. | -| objectStorage.mcg.namespace | string | `"openshift-storage"` | Namespace of the NooBaa system. Must match the ODF operator namespace. | -| objectStorage.mcg.pvPool.numVolumes | int | `1` | Number of persistent volumes in the backing store pool. | -| objectStorage.mcg.pvPool.resources.limits.cpu | string | `"1"` | | -| objectStorage.mcg.pvPool.resources.limits.memory | string | `"4Gi"` | | -| objectStorage.mcg.pvPool.resources.requests.cpu | string | `"800m"` | | -| objectStorage.mcg.pvPool.resources.requests.memory | string | `"800Mi"` | | -| objectStorage.mcg.pvPool.resources.requests.storage | string | `"50Gi"` | | -| objectStorage.mcg.system.name | string | `"noobaa"` | Name of the NooBaa custom resource. | -| objectStorage.mode | string | `"mcg"` | mcg deploys the standalone Multicloud Object Gateway. odf consumes an existing OpenShift Data Foundation StorageCluster. | -| objectStorage.objectBucketClaim.bucketName | string | `"quay-datastore"` | Prefix passed to generateBucketName. | -| objectStorage.objectBucketClaim.name | string | `"quay-bucket"` | ObjectBucketClaim name. The bound ConfigMap and Secret use this name. | -| objectStorage.objectBucketClaim.storageClass | string | `"openshift-storage.noobaa.io"` | StorageClass for the claim. Override for Ceph RGW, for example ocs-storagecluster-ceph-rgw. | -| quay.configBundleSecret.deploy | bool | `true` | | -| quay.configBundleSecret.name | string | `"quay-init-config-bundle-secret"` | Template secret. The S3 job copies it and fills storage placeholders. | -| quay.configBundleSecret.s3Name | string | `"quay-config-with-s3"` | Secret QuayRegistry reads after the S3 job fills credentials. | -| quay.name | string | `"quay-registry"` | Name of the QuayRegistry resource. The managed route is this name with -quay appended. | -| quay.namespace | string | `"quay-enterprise"` | Namespace for the Quay registry and its configuration jobs. | -| quay.storage.clairpostgres.size | string | `"50Gi"` | Persistent volume size for the Clair PostgreSQL database. | -| quay.storage.postgres.size | string | `"50Gi"` | Persistent volume size for the Quay PostgreSQL database. | -| quayConfig.credentials.key | string | `"secret/data/hub/infra/quay/quay-users"` | Vault (or other backend) path extracted into quay-config-credentials. | -| quayConfig.organizations[0].email | string | `"devel@myorg.com"` | | -| quayConfig.organizations[0].name | string | `"devel"` | | -| quayConfig.repositories[0].name | string | `"devel/example"` | | -| quayConfig.repositories[0].visibility | string | `"private"` | | -| quayConfig.users[0].email | string | `"quayadmin@example.com"` | | -| quayConfig.users[0].initialize | bool | `true` | | -| quayConfig.users[0].name | string | `"quayadmin"` | | -| quayConfig.users[0].passwordProperty | string | `"quay-admin-password"` | Property on credentials.key projected into the credentials Secret. | -| quayConfig.users[0].superuser | bool | `true` | | -| quayConfig.users[1].email | string | `"developer1@myorg.com"` | | -| quayConfig.users[1].initialize | bool | `false` | | -| quayConfig.users[1].name | string | `"developer1"` | | -| quayConfig.users[1].passwordProperty | string | `"quay-user-password"` | | -| quayConfig.users[1].superuser | bool | `false` | | -| secretStore.kind | string | `"ClusterSecretStore"` | Kind of secretStore.name. | -| secretStore.name | string | `"vault-backend"` | SecretStore or ClusterSecretStore that holds Quay user passwords. | - +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| configJob.activeDeadlineSeconds | int | `1800` | Job activeDeadlineSeconds for the bootstrap Job and CronJob pods. | +| configJob.backoffLimit | int | `5` | | +| configJob.collectionVersion | string | `"2.8.1"` | infra.quay_configuration version passed to ansible-galaxy. | +| configJob.enabled | bool | `true` | Run the bootstrap Job and the reconciling CronJob. | +| configJob.failedJobsHistoryLimit | int | `1` | | +| configJob.image | string | `"quay.io/hybridcloudpatterns/imperative-container:v1"` | Image with ansible-core, ansible-galaxy, oc, and cURL. | +| configJob.imagePullPolicy | string | `"Always"` | | +| configJob.installCollection | bool | `true` | Install infra.quay_configuration into an emptyDir before the playbook. Set false when configJob.image already contains the collection. | +| configJob.resources.limits.cpu | string | `"500m"` | | +| configJob.resources.limits.memory | string | `"512Mi"` | | +| configJob.resources.requests.cpu | string | `"50m"` | | +| configJob.resources.requests.memory | string | `"256Mi"` | | +| configJob.schedule | string | `"*/30 * * * *"` | Cron schedule for re-applying Quay configuration. | +| configJob.successfulJobsHistoryLimit | int | `1` | | +| configJob.validateCerts | bool | `false` | Verify the Quay route TLS certificate. In-cluster routes often use a private CA, so the default is false. | +| consoleLink.enabled | bool | `true` | Create an ApplicationMenu ConsoleLink for the Quay route. | +| consoleLink.href | string | `""` | Override the computed route URL. Empty builds the managed route from quay.name, quay.namespace, and global.clusterDomain. | +| consoleLink.name | string | `"quay"` | ConsoleLink metadata.name. | +| consoleLink.section | string | `"Red Hat applications"` | Application menu section. | +| consoleLink.text | string | `"Red Hat Quay"` | Menu text. | +| global.clusterDomain | string | `"example.com"` | OpenShift cluster base domain. Used in the console link when consoleLink.href is empty. The host is apps. plus this domain. | +| job.image | string | `"registry.redhat.io/openshift4/ose-cli-rhel9:latest"` | Image for the S3 credentials Job. Uses the OpenShift cli ImageStream, which tracks the cluster version. Override when the internal registry is unavailable, for example ose-cli-rhel9:v4.20. | +| job.resources.limits.cpu | string | `"500m"` | | +| job.resources.limits.memory | string | `"512Mi"` | | +| job.resources.requests.cpu | string | `"50m"` | | +| job.resources.requests.memory | string | `"128Mi"` | | +| objectStorage.mcg.backingStore.name | string | `"noobaa-default-backing-store"` | BackingStore that holds the gateway's persistent volumes. | +| objectStorage.mcg.bucketClass.name | string | `"noobaa-default-bucket-class"` | BucketClass used by the default NooBaa storage class. | +| objectStorage.mcg.bucketClass.placement.tiers[0].backingStores[0] | string | `"noobaa-default-backing-store"` | | +| objectStorage.mcg.dbSize | string | `"50Gi"` | Size of the NooBaa PostgreSQL volume. | +| objectStorage.mcg.namespace | string | `"openshift-storage"` | Namespace of the NooBaa system. Must match the ODF operator namespace. | +| objectStorage.mcg.pvPool.numVolumes | int | `1` | Number of persistent volumes in the backing store pool. | +| objectStorage.mcg.pvPool.resources.limits.cpu | string | `"1"` | | +| objectStorage.mcg.pvPool.resources.limits.memory | string | `"4Gi"` | | +| objectStorage.mcg.pvPool.resources.requests.cpu | string | `"800m"` | | +| objectStorage.mcg.pvPool.resources.requests.memory | string | `"800Mi"` | | +| objectStorage.mcg.pvPool.resources.requests.storage | string | `"50Gi"` | | +| objectStorage.mcg.system.name | string | `"noobaa"` | Name of the NooBaa custom resource. | +| objectStorage.mode | string | `"mcg"` | mcg deploys the standalone Multicloud Object Gateway. odf consumes an existing OpenShift Data Foundation StorageCluster. | +| objectStorage.objectBucketClaim.bucketName | string | `"quay-datastore"` | Prefix passed to generateBucketName. | +| objectStorage.objectBucketClaim.name | string | `"quay-bucket"` | ObjectBucketClaim name. The bound ConfigMap and Secret use this name. | +| objectStorage.objectBucketClaim.storageClass | string | `"openshift-storage.noobaa.io"` | StorageClass for the claim. Override for Ceph RGW, for example ocs-storagecluster-ceph-rgw. | +| quay.configBundleSecret.deploy | bool | `true` | | +| quay.configBundleSecret.name | string | `"quay-init-config-bundle-secret"` | Template secret. The S3 job copies it and fills storage placeholders. | +| quay.configBundleSecret.s3Name | string | `"quay-config-with-s3"` | Secret QuayRegistry reads after the S3 job fills credentials. | +| quay.name | string | `"quay-registry"` | Name of the QuayRegistry resource. The managed route is this name with -quay appended. | +| quay.namespace | string | `"quay-enterprise"` | Namespace for the Quay registry and its configuration jobs. | +| quay.storage.clairpostgres.size | string | `"50Gi"` | Persistent volume size for the Clair PostgreSQL database. | +| quay.storage.postgres.size | string | `"50Gi"` | Persistent volume size for the Quay PostgreSQL database. | +| quayConfig.credentials.key | string | `"secret/data/hub/infra/quay/quay-users"` | Vault (or other backend) path extracted into quay-config-credentials. | +| quayConfig.organizations[0].email | string | `"devel@myorg.com"` | | +| quayConfig.organizations[0].name | string | `"devel"` | | +| quayConfig.repositories[0].name | string | `"devel/example"` | | +| quayConfig.repositories[0].visibility | string | `"private"` | | +| quayConfig.users[0].email | string | `"quayadmin@example.com"` | | +| quayConfig.users[0].initialize | bool | `true` | | +| quayConfig.users[0].name | string | `"quayadmin"` | | +| quayConfig.users[0].passwordProperty | string | `"quay-admin-password"` | Property on credentials.key projected into the credentials Secret. | +| quayConfig.users[0].superuser | bool | `true` | | +| quayConfig.users[1].email | string | `"developer1@myorg.com"` | | +| quayConfig.users[1].initialize | bool | `false` | | +| quayConfig.users[1].name | string | `"developer1"` | | +| quayConfig.users[1].passwordProperty | string | `"quay-user-password"` | | +| quayConfig.users[1].superuser | bool | `false` | | +| secretStore.kind | string | `"ClusterSecretStore"` | Kind of secretStore.name. | +| secretStore.name | string | `"vault-backend"` | SecretStore or ClusterSecretStore that holds Quay user passwords. | ---- - +---------------------------------------------- Autogenerated from chart metadata using [helm-docs v1.14.2](https://github.com/norwoodj/helm-docs/releases/v1.14.2) diff --git a/values.yaml b/values.yaml index 0be8c67..ff8034d 100644 --- a/values.yaml +++ b/values.yaml @@ -27,14 +27,14 @@ job: # -- Image for the S3 credentials Job. Uses the OpenShift cli ImageStream, # which tracks the cluster version. Override when the internal # registry is unavailable, for example ose-cli-rhel9:v4.20. - image: image-registry.openshift-image-registry.svc:5000/openshift/cli + image: "registry.redhat.io/openshift4/ose-cli-rhel9:latest" resources: requests: cpu: 50m memory: 128Mi limits: cpu: 500m - memory: 256Mi + memory: 512Mi # Object storage. mode mcg deploys the standalone Multicloud Object Gateway. # mode odf uses the NooBaa gateway that a StorageCluster already created. From c9745cecbdc60647128e5e483766b4454f6d9bb6 Mon Sep 17 00:00:00 2001 From: Martin Jackson Date: Fri, 2 Oct 2026 13:16:54 -0500 Subject: [PATCH 3/9] Update defaults --- README.md | 13 +++---------- values.yaml | 34 ++++++++++++++++++++++------------ 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 8929286..36b04f0 100644 --- a/README.md +++ b/README.md @@ -85,21 +85,14 @@ This chart is used to serve as the template for Validated Patterns Charts | quay.namespace | string | `"quay-enterprise"` | Namespace for the Quay registry and its configuration jobs. | | quay.storage.clairpostgres.size | string | `"50Gi"` | Persistent volume size for the Clair PostgreSQL database. | | quay.storage.postgres.size | string | `"50Gi"` | Persistent volume size for the Quay PostgreSQL database. | -| quayConfig.credentials.key | string | `"secret/data/hub/infra/quay/quay-users"` | Vault (or other backend) path extracted into quay-config-credentials. | -| quayConfig.organizations[0].email | string | `"devel@myorg.com"` | | -| quayConfig.organizations[0].name | string | `"devel"` | | -| quayConfig.repositories[0].name | string | `"devel/example"` | | -| quayConfig.repositories[0].visibility | string | `"private"` | | +| quayConfig.credentials.key | string | `"secret/data/hub/quay-users"` | Vault (or other backend) path extracted into quay-config-credentials. | +| quayConfig.organizations | list | `[]` | | +| quayConfig.repositories | list | `[]` | | | quayConfig.users[0].email | string | `"quayadmin@example.com"` | | | quayConfig.users[0].initialize | bool | `true` | | | quayConfig.users[0].name | string | `"quayadmin"` | | | quayConfig.users[0].passwordProperty | string | `"quay-admin-password"` | Property on credentials.key projected into the credentials Secret. | | quayConfig.users[0].superuser | bool | `true` | | -| quayConfig.users[1].email | string | `"developer1@myorg.com"` | | -| quayConfig.users[1].initialize | bool | `false` | | -| quayConfig.users[1].name | string | `"developer1"` | | -| quayConfig.users[1].passwordProperty | string | `"quay-user-password"` | | -| quayConfig.users[1].superuser | bool | `false` | | | secretStore.kind | string | `"ClusterSecretStore"` | Kind of secretStore.name. | | secretStore.name | string | `"vault-backend"` | SecretStore or ClusterSecretStore that holds Quay user passwords. | diff --git a/values.yaml b/values.yaml index ff8034d..7263e20 100644 --- a/values.yaml +++ b/values.yaml @@ -89,7 +89,7 @@ secretStore: quayConfig: credentials: # -- Vault (or other backend) path extracted into quay-config-credentials. - key: secret/data/hub/infra/quay/quay-users + key: secret/data/hub/quay-users # Exactly one user must set initialize, and that user must be a superuser. # Organization names must be at least four characters. Repository names # use the namespace/name form. @@ -100,17 +100,27 @@ quayConfig: passwordProperty: quay-admin-password superuser: true initialize: true - - name: developer1 - email: developer1@myorg.com - passwordProperty: quay-user-password - superuser: false - initialize: false - organizations: - - name: devel - email: devel@myorg.com - repositories: - - name: devel/example - visibility: private + organizations: [] + repositories: [] + +# users: +# - name: quayadmin +# email: quayadmin@example.com +# # -- Property on credentials.key projected into the credentials Secret. +# passwordProperty: quay-admin-password +# superuser: true +# initialize: true +# - name: developer1 +# email: developer1@myorg.com +# passwordProperty: quay-user-password +# superuser: false +# initialize: false +# organizations: +# - name: devel +# email: devel@myorg.com +# repositories: +# - name: devel/example +# visibility: private consoleLink: # -- Create an ApplicationMenu ConsoleLink for the Quay route. From 0515a918381d3abe2b78f807140523d233141c57 Mon Sep 17 00:00:00 2001 From: Martin Jackson Date: Fri, 2 Oct 2026 13:39:33 -0500 Subject: [PATCH 4/9] Use imperative image --- README.md | 2 +- values.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 36b04f0..f3030d5 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ This chart is used to serve as the template for Validated Patterns Charts | consoleLink.section | string | `"Red Hat applications"` | Application menu section. | | consoleLink.text | string | `"Red Hat Quay"` | Menu text. | | global.clusterDomain | string | `"example.com"` | OpenShift cluster base domain. Used in the console link when consoleLink.href is empty. The host is apps. plus this domain. | -| job.image | string | `"registry.redhat.io/openshift4/ose-cli-rhel9:latest"` | Image for the S3 credentials Job. Uses the OpenShift cli ImageStream, which tracks the cluster version. Override when the internal registry is unavailable, for example ose-cli-rhel9:v4.20. | +| job.image | string | `"quay.io/validatedpatterns/imperative-image:v1"` | Image for the S3 credentials Job. Uses the OpenShift cli ImageStream, which tracks the cluster version. Override when the internal registry is unavailable, for example ose-cli-rhel9:v4.20. | | job.resources.limits.cpu | string | `"500m"` | | | job.resources.limits.memory | string | `"512Mi"` | | | job.resources.requests.cpu | string | `"50m"` | | diff --git a/values.yaml b/values.yaml index 7263e20..a265468 100644 --- a/values.yaml +++ b/values.yaml @@ -27,7 +27,7 @@ job: # -- Image for the S3 credentials Job. Uses the OpenShift cli ImageStream, # which tracks the cluster version. Override when the internal # registry is unavailable, for example ose-cli-rhel9:v4.20. - image: "registry.redhat.io/openshift4/ose-cli-rhel9:latest" + image: "quay.io/validatedpatterns/imperative-image:v1" resources: requests: cpu: 50m From 60651b21b547329ea7a0fded6464f1e817765f47 Mon Sep 17 00:00:00 2001 From: Martin Jackson Date: Fri, 2 Oct 2026 13:44:25 -0500 Subject: [PATCH 5/9] Use right image --- README.md | 2 +- values.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f3030d5..6403c65 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ This chart is used to serve as the template for Validated Patterns Charts | consoleLink.section | string | `"Red Hat applications"` | Application menu section. | | consoleLink.text | string | `"Red Hat Quay"` | Menu text. | | global.clusterDomain | string | `"example.com"` | OpenShift cluster base domain. Used in the console link when consoleLink.href is empty. The host is apps. plus this domain. | -| job.image | string | `"quay.io/validatedpatterns/imperative-image:v1"` | Image for the S3 credentials Job. Uses the OpenShift cli ImageStream, which tracks the cluster version. Override when the internal registry is unavailable, for example ose-cli-rhel9:v4.20. | +| job.image | string | `"quay.io/validatedpatterns/imperative-container:v1"` | Image for the S3 credentials Job. Uses the OpenShift cli ImageStream, which tracks the cluster version. Override when the internal registry is unavailable, for example ose-cli-rhel9:v4.20. | | job.resources.limits.cpu | string | `"500m"` | | | job.resources.limits.memory | string | `"512Mi"` | | | job.resources.requests.cpu | string | `"50m"` | | diff --git a/values.yaml b/values.yaml index a265468..2af3823 100644 --- a/values.yaml +++ b/values.yaml @@ -27,7 +27,7 @@ job: # -- Image for the S3 credentials Job. Uses the OpenShift cli ImageStream, # which tracks the cluster version. Override when the internal # registry is unavailable, for example ose-cli-rhel9:v4.20. - image: "quay.io/validatedpatterns/imperative-image:v1" + image: "quay.io/validatedpatterns/imperative-container:v1" resources: requests: cpu: 50m From cff981b7271014ac09b1f3c9f9205035511727e8 Mon Sep 17 00:00:00 2001 From: Martin Jackson Date: Fri, 2 Oct 2026 13:49:38 -0500 Subject: [PATCH 6/9] Make working dir writable --- README.md | 4 ++-- files/quay-config-run.sh | 9 ++++++--- templates/_helpers.tpl | 14 ++++++++------ tests/quay_config_job_test.yaml | 17 +++++++++++++++++ tests/quay_config_playbook_test.yaml | 3 +++ values.yaml | 10 +++++----- 6 files changed, 41 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 6403c65..22bb006 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ This chart is used to serve as the template for Validated Patterns Charts | configJob.collectionVersion | string | `"2.8.1"` | infra.quay_configuration version passed to ansible-galaxy. | | configJob.enabled | bool | `true` | Run the bootstrap Job and the reconciling CronJob. | | configJob.failedJobsHistoryLimit | int | `1` | | -| configJob.image | string | `"quay.io/hybridcloudpatterns/imperative-container:v1"` | Image with ansible-core, ansible-galaxy, oc, and cURL. | +| configJob.image | string | `"quay.io/validatedpatterns/imperative-container:v1"` | Image with ansible-core, ansible-galaxy, oc, and cURL. Same default as job.image. The container home is /pattern-home. | | configJob.imagePullPolicy | string | `"Always"` | | | configJob.installCollection | bool | `true` | Install infra.quay_configuration into an emptyDir before the playbook. Set false when configJob.image already contains the collection. | | configJob.resources.limits.cpu | string | `"500m"` | | @@ -57,7 +57,7 @@ This chart is used to serve as the template for Validated Patterns Charts | consoleLink.section | string | `"Red Hat applications"` | Application menu section. | | consoleLink.text | string | `"Red Hat Quay"` | Menu text. | | global.clusterDomain | string | `"example.com"` | OpenShift cluster base domain. Used in the console link when consoleLink.href is empty. The host is apps. plus this domain. | -| job.image | string | `"quay.io/validatedpatterns/imperative-container:v1"` | Image for the S3 credentials Job. Uses the OpenShift cli ImageStream, which tracks the cluster version. Override when the internal registry is unavailable, for example ose-cli-rhel9:v4.20. | +| job.image | string | `"quay.io/validatedpatterns/imperative-container:v1"` | Image for the S3 credentials Job. The Validated Patterns imperative container provides oc. | | job.resources.limits.cpu | string | `"500m"` | | | job.resources.limits.memory | string | `"512Mi"` | | | job.resources.requests.cpu | string | `"50m"` | | diff --git a/files/quay-config-run.sh b/files/quay-config-run.sh index e66e93b..d0ad030 100755 --- a/files/quay-config-run.sh +++ b/files/quay-config-run.sh @@ -6,7 +6,7 @@ quay_namespace="${QUAY_NAMESPACE:?QUAY_NAMESPACE is required}" quay_route="${QUAY_ROUTE:?QUAY_ROUTE is required}" install_collection="${INSTALL_COLLECTION:?INSTALL_COLLECTION is required}" validate_certs="${VALIDATE_CERTS:?VALIDATE_CERTS is required}" -home_dir="${HOME_DIR:-/quay-work}" +home_dir="${HOME_DIR:-/pattern-home}" route_host="" echo "Waiting for route ${quay_route} in ${quay_namespace}..." @@ -50,8 +50,11 @@ if [[ "${install_collection}" == "true" ]]; then fi export HOME="${home_dir}" -export ANSIBLE_LOCAL_TEMP="${HOME}/tmp" -mkdir -p "${ANSIBLE_LOCAL_TEMP}" +# The imperative container sets ANSIBLE_REMOTE_TMP to ${HOME}/.ansible/tmp. +# Recreate that directory on the writable home mount before the playbook runs. +export ANSIBLE_REMOTE_TMP="${home_dir}/.ansible/tmp" +export ANSIBLE_LOCAL_TEMP="${home_dir}/.ansible/tmp" +mkdir -p "${ANSIBLE_REMOTE_TMP}" ansible-playbook /quay-config/playbook.yml \ -e "quay_host=${quay_host}" \ diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl index d722c32..b4f3d68 100644 --- a/templates/_helpers.tpl +++ b/templates/_helpers.tpl @@ -177,9 +177,9 @@ initContainers: {{- toYaml .Values.configJob.resources | nindent 6 }} env: - name: COLLECTIONS_PATH - value: /quay-work/collections + value: /pattern-home/collections - name: HOME - value: /quay-work + value: /pattern-home command: - /bin/bash - /quay-config/install.sh @@ -187,7 +187,7 @@ initContainers: - name: tmp mountPath: /tmp - name: work - mountPath: /quay-work + mountPath: /pattern-home - name: quay-config mountPath: /quay-config readOnly: true @@ -210,9 +210,11 @@ containers: - name: VALIDATE_CERTS value: {{ .Values.configJob.validateCerts | quote }} - name: COLLECTIONS_PATH - value: /quay-work/collections + value: /pattern-home/collections - name: HOME_DIR - value: /quay-work + value: /pattern-home + - name: ANSIBLE_REMOTE_TMP + value: /pattern-home/.ansible/tmp command: - /bin/bash - /quay-config/run.sh @@ -220,7 +222,7 @@ containers: - name: tmp mountPath: /tmp - name: work - mountPath: /quay-work + mountPath: /pattern-home - name: quay-config mountPath: /quay-config readOnly: true diff --git a/tests/quay_config_job_test.yaml b/tests/quay_config_job_test.yaml index 03b2926..88614fe 100644 --- a/tests/quay_config_job_test.yaml +++ b/tests/quay_config_job_test.yaml @@ -21,6 +21,23 @@ tests: - equal: path: spec.template.spec.containers[0].command[1] value: /quay-config/run.sh + - equal: + path: spec.template.spec.containers[0].image + value: quay.io/validatedpatterns/imperative-container:v1 + - equal: + path: spec.template.spec.containers[0].env[5].name + value: HOME_DIR + - equal: + path: spec.template.spec.containers[0].env[5].value + value: /pattern-home + - equal: + path: spec.template.spec.volumes[1].name + value: work + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: work + mountPath: /pattern-home - it: skips collection install when the image already provides it set: configJob: diff --git a/tests/quay_config_playbook_test.yaml b/tests/quay_config_playbook_test.yaml index d25c0d0..22c329b 100644 --- a/tests/quay_config_playbook_test.yaml +++ b/tests/quay_config_playbook_test.yaml @@ -19,3 +19,6 @@ tests: - matchRegex: path: data["install.sh"] pattern: "ansible-galaxy collection install" + - matchRegex: + path: data["run.sh"] + pattern: "ANSIBLE_REMOTE_TMP" diff --git a/values.yaml b/values.yaml index 2af3823..a533d8d 100644 --- a/values.yaml +++ b/values.yaml @@ -24,10 +24,9 @@ quay: size: 50Gi job: - # -- Image for the S3 credentials Job. Uses the OpenShift cli ImageStream, - # which tracks the cluster version. Override when the internal - # registry is unavailable, for example ose-cli-rhel9:v4.20. - image: "quay.io/validatedpatterns/imperative-container:v1" + # -- Image for the S3 credentials Job. The Validated Patterns imperative + # container provides oc. + image: quay.io/validatedpatterns/imperative-container:v1 resources: requests: cpu: 50m @@ -139,7 +138,8 @@ configJob: # -- Run the bootstrap Job and the reconciling CronJob. enabled: true # -- Image with ansible-core, ansible-galaxy, oc, and cURL. - image: quay.io/hybridcloudpatterns/imperative-container:v1 + # Same default as job.image. The container home is /pattern-home. + image: quay.io/validatedpatterns/imperative-container:v1 imagePullPolicy: Always # -- Cron schedule for re-applying Quay configuration. schedule: "*/30 * * * *" From ad030012a0ea14d4c777b384a06ed00b01d13aa3 Mon Sep 17 00:00:00 2001 From: Martin Jackson Date: Fri, 2 Oct 2026 13:56:42 -0500 Subject: [PATCH 7/9] Gate config jobs --- README.md | 1 + templates/quay-config-bundle-secret.yaml | 2 ++ templates/quay-config-configmap.yaml | 2 +- templates/quay-config-cronjob.yaml | 2 +- templates/quay-config-externalsecret.yaml | 2 +- templates/quay-config-job.yaml | 2 +- templates/quay-config-rbac.yaml | 2 +- tests/config_bundle_test.yaml | 11 ++++++++++ tests/quay_config_cronjob_test.yaml | 7 ++++++ tests/quay_config_job_test.yaml | 7 ++++++ tests/quay_config_playbook_test.yaml | 26 +++++++++++++++++++++++ tests/quay_config_secret_test.yaml | 22 +++++++++++++++++++ values.yaml | 3 +++ 13 files changed, 84 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 22bb006..55b7525 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ This chart is used to serve as the template for Validated Patterns Charts | quay.storage.clairpostgres.size | string | `"50Gi"` | Persistent volume size for the Clair PostgreSQL database. | | quay.storage.postgres.size | string | `"50Gi"` | Persistent volume size for the Quay PostgreSQL database. | | quayConfig.credentials.key | string | `"secret/data/hub/quay-users"` | Vault (or other backend) path extracted into quay-config-credentials. | +| quayConfig.enabled | bool | `true` | Create the configuration Job, CronJob, and credential Secret. Set false to leave users, organizations, and repositories unmanaged. | | quayConfig.organizations | list | `[]` | | | quayConfig.repositories | list | `[]` | | | quayConfig.users[0].email | string | `"quayadmin@example.com"` | | diff --git a/templates/quay-config-bundle-secret.yaml b/templates/quay-config-bundle-secret.yaml index 6bdd4fa..a6821b8 100644 --- a/templates/quay-config-bundle-secret.yaml +++ b/templates/quay-config-bundle-secret.yaml @@ -1,4 +1,6 @@ +{{- if .Values.quayConfig.enabled }} {{- include "quay.config.validate" . -}} +{{- end }} apiVersion: v1 kind: Secret metadata: diff --git a/templates/quay-config-configmap.yaml b/templates/quay-config-configmap.yaml index e761a76..6ebca5a 100644 --- a/templates/quay-config-configmap.yaml +++ b/templates/quay-config-configmap.yaml @@ -1,4 +1,4 @@ -{{- if .Values.configJob.enabled }} +{{- if and .Values.quayConfig.enabled .Values.configJob.enabled }} apiVersion: v1 kind: ConfigMap metadata: diff --git a/templates/quay-config-cronjob.yaml b/templates/quay-config-cronjob.yaml index e53662a..5809120 100644 --- a/templates/quay-config-cronjob.yaml +++ b/templates/quay-config-cronjob.yaml @@ -1,4 +1,4 @@ -{{- if .Values.configJob.enabled }} +{{- if and .Values.quayConfig.enabled .Values.configJob.enabled }} apiVersion: batch/v1 kind: CronJob metadata: diff --git a/templates/quay-config-externalsecret.yaml b/templates/quay-config-externalsecret.yaml index 825c271..a54bf26 100644 --- a/templates/quay-config-externalsecret.yaml +++ b/templates/quay-config-externalsecret.yaml @@ -1,4 +1,4 @@ -{{- if .Values.configJob.enabled }} +{{- if and .Values.quayConfig.enabled .Values.configJob.enabled }} {{- include "quay.config.validate" . -}} apiVersion: external-secrets.io/v1 kind: ExternalSecret diff --git a/templates/quay-config-job.yaml b/templates/quay-config-job.yaml index 5d13df3..3ba469f 100644 --- a/templates/quay-config-job.yaml +++ b/templates/quay-config-job.yaml @@ -1,4 +1,4 @@ -{{- if .Values.configJob.enabled }} +{{- if and .Values.quayConfig.enabled .Values.configJob.enabled }} apiVersion: batch/v1 kind: Job metadata: diff --git a/templates/quay-config-rbac.yaml b/templates/quay-config-rbac.yaml index 091bd51..8e69392 100644 --- a/templates/quay-config-rbac.yaml +++ b/templates/quay-config-rbac.yaml @@ -1,4 +1,4 @@ -{{- if .Values.configJob.enabled }} +{{- if and .Values.quayConfig.enabled .Values.configJob.enabled }} apiVersion: v1 kind: ServiceAccount metadata: diff --git a/tests/config_bundle_test.yaml b/tests/config_bundle_test.yaml index 6bc8708..53fa04e 100644 --- a/tests/config_bundle_test.yaml +++ b/tests/config_bundle_test.yaml @@ -16,3 +16,14 @@ tests: - matchRegex: path: stringData["config.yaml"] pattern: "- quayadmin" + - it: still renders the bundle when quayConfig is disabled + set: + quayConfig: + enabled: false + users: [] + asserts: + - isKind: + of: Secret + - matchRegex: + path: stringData["config.yaml"] + pattern: PLACEHOLDER_BUCKET_HOST diff --git a/tests/quay_config_cronjob_test.yaml b/tests/quay_config_cronjob_test.yaml index f760b1c..3e65b5b 100644 --- a/tests/quay_config_cronjob_test.yaml +++ b/tests/quay_config_cronjob_test.yaml @@ -22,3 +22,10 @@ tests: asserts: - hasDocuments: count: 0 + - it: does not render when quayConfig is disabled + set: + quayConfig: + enabled: false + asserts: + - hasDocuments: + count: 0 diff --git a/tests/quay_config_job_test.yaml b/tests/quay_config_job_test.yaml index 88614fe..bd720fa 100644 --- a/tests/quay_config_job_test.yaml +++ b/tests/quay_config_job_test.yaml @@ -52,3 +52,10 @@ tests: asserts: - hasDocuments: count: 0 + - it: does not render when quayConfig is disabled + set: + quayConfig: + enabled: false + asserts: + - hasDocuments: + count: 0 diff --git a/tests/quay_config_playbook_test.yaml b/tests/quay_config_playbook_test.yaml index 22c329b..753960d 100644 --- a/tests/quay_config_playbook_test.yaml +++ b/tests/quay_config_playbook_test.yaml @@ -3,6 +3,25 @@ templates: - templates/quay-config-configmap.yaml tests: - it: renders a playbook for the collection + set: + quayConfig: + users: + - name: quayadmin + email: quayadmin@example.com + passwordProperty: quay-admin-password + superuser: true + initialize: true + - name: developer1 + email: developer1@myorg.com + passwordProperty: quay-user-password + superuser: false + initialize: false + organizations: + - name: devel + email: devel@myorg.com + repositories: + - name: devel/example + visibility: private asserts: - matchRegex: path: data["requirements.yml"] @@ -22,3 +41,10 @@ tests: - matchRegex: path: data["run.sh"] pattern: "ANSIBLE_REMOTE_TMP" + - it: does not render when quayConfig is disabled + set: + quayConfig: + enabled: false + asserts: + - hasDocuments: + count: 0 diff --git a/tests/quay_config_secret_test.yaml b/tests/quay_config_secret_test.yaml index be7063c..c916e8f 100644 --- a/tests/quay_config_secret_test.yaml +++ b/tests/quay_config_secret_test.yaml @@ -3,6 +3,21 @@ templates: - templates/quay-config-externalsecret.yaml tests: - it: projects user passwords from the secret store + set: + quayConfig: + credentials: + key: secret/data/hub/infra/quay/quay-users + users: + - name: quayadmin + email: quayadmin@example.com + passwordProperty: quay-admin-password + superuser: true + initialize: true + - name: developer1 + email: developer1@myorg.com + passwordProperty: quay-user-password + superuser: false + initialize: false asserts: - isKind: of: ExternalSecret @@ -34,3 +49,10 @@ tests: asserts: - hasDocuments: count: 0 + - it: does not render when quayConfig is disabled + set: + quayConfig: + enabled: false + asserts: + - hasDocuments: + count: 0 diff --git a/values.yaml b/values.yaml index a533d8d..f52c823 100644 --- a/values.yaml +++ b/values.yaml @@ -86,6 +86,9 @@ secretStore: kind: ClusterSecretStore quayConfig: + # -- Create the configuration Job, CronJob, and credential Secret. + # Set false to leave users, organizations, and repositories unmanaged. + enabled: true credentials: # -- Vault (or other backend) path extracted into quay-config-credentials. key: secret/data/hub/quay-users From 84f0b79322af37d051a5833af33151455908d69e Mon Sep 17 00:00:00 2001 From: Martin Jackson Date: Fri, 2 Oct 2026 19:42:20 -0500 Subject: [PATCH 8/9] Go to one enabled flag --- README.md | 3 +-- templates/quay-config-configmap.yaml | 2 +- templates/quay-config-cronjob.yaml | 2 +- templates/quay-config-externalsecret.yaml | 2 +- templates/quay-config-job.yaml | 2 +- templates/quay-config-rbac.yaml | 2 +- tests/quay_config_cronjob_test.yaml | 7 ------- tests/quay_config_job_test.yaml | 7 ------- tests/quay_config_secret_test.yaml | 7 ------- values.yaml | 6 ++---- 10 files changed, 8 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 55b7525..51cba5b 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,6 @@ This chart is used to serve as the template for Validated Patterns Charts | configJob.activeDeadlineSeconds | int | `1800` | Job activeDeadlineSeconds for the bootstrap Job and CronJob pods. | | configJob.backoffLimit | int | `5` | | | configJob.collectionVersion | string | `"2.8.1"` | infra.quay_configuration version passed to ansible-galaxy. | -| configJob.enabled | bool | `true` | Run the bootstrap Job and the reconciling CronJob. | | configJob.failedJobsHistoryLimit | int | `1` | | | configJob.image | string | `"quay.io/validatedpatterns/imperative-container:v1"` | Image with ansible-core, ansible-galaxy, oc, and cURL. Same default as job.image. The container home is /pattern-home. | | configJob.imagePullPolicy | string | `"Always"` | | @@ -86,7 +85,7 @@ This chart is used to serve as the template for Validated Patterns Charts | quay.storage.clairpostgres.size | string | `"50Gi"` | Persistent volume size for the Clair PostgreSQL database. | | quay.storage.postgres.size | string | `"50Gi"` | Persistent volume size for the Quay PostgreSQL database. | | quayConfig.credentials.key | string | `"secret/data/hub/quay-users"` | Vault (or other backend) path extracted into quay-config-credentials. | -| quayConfig.enabled | bool | `true` | Create the configuration Job, CronJob, and credential Secret. Set false to leave users, organizations, and repositories unmanaged. | +| quayConfig.enabled | bool | `true` | Apply users, organizations, and repositories. Set false to skip the configuration Job, CronJob, ConfigMap, ExternalSecret, and RBAC. | | quayConfig.organizations | list | `[]` | | | quayConfig.repositories | list | `[]` | | | quayConfig.users[0].email | string | `"quayadmin@example.com"` | | diff --git a/templates/quay-config-configmap.yaml b/templates/quay-config-configmap.yaml index 6ebca5a..37d9adb 100644 --- a/templates/quay-config-configmap.yaml +++ b/templates/quay-config-configmap.yaml @@ -1,4 +1,4 @@ -{{- if and .Values.quayConfig.enabled .Values.configJob.enabled }} +{{- if .Values.quayConfig.enabled }} apiVersion: v1 kind: ConfigMap metadata: diff --git a/templates/quay-config-cronjob.yaml b/templates/quay-config-cronjob.yaml index 5809120..535a137 100644 --- a/templates/quay-config-cronjob.yaml +++ b/templates/quay-config-cronjob.yaml @@ -1,4 +1,4 @@ -{{- if and .Values.quayConfig.enabled .Values.configJob.enabled }} +{{- if .Values.quayConfig.enabled }} apiVersion: batch/v1 kind: CronJob metadata: diff --git a/templates/quay-config-externalsecret.yaml b/templates/quay-config-externalsecret.yaml index a54bf26..9c9e1b9 100644 --- a/templates/quay-config-externalsecret.yaml +++ b/templates/quay-config-externalsecret.yaml @@ -1,4 +1,4 @@ -{{- if and .Values.quayConfig.enabled .Values.configJob.enabled }} +{{- if .Values.quayConfig.enabled }} {{- include "quay.config.validate" . -}} apiVersion: external-secrets.io/v1 kind: ExternalSecret diff --git a/templates/quay-config-job.yaml b/templates/quay-config-job.yaml index 3ba469f..7e7279c 100644 --- a/templates/quay-config-job.yaml +++ b/templates/quay-config-job.yaml @@ -1,4 +1,4 @@ -{{- if and .Values.quayConfig.enabled .Values.configJob.enabled }} +{{- if .Values.quayConfig.enabled }} apiVersion: batch/v1 kind: Job metadata: diff --git a/templates/quay-config-rbac.yaml b/templates/quay-config-rbac.yaml index 8e69392..1e385e9 100644 --- a/templates/quay-config-rbac.yaml +++ b/templates/quay-config-rbac.yaml @@ -1,4 +1,4 @@ -{{- if and .Values.quayConfig.enabled .Values.configJob.enabled }} +{{- if .Values.quayConfig.enabled }} apiVersion: v1 kind: ServiceAccount metadata: diff --git a/tests/quay_config_cronjob_test.yaml b/tests/quay_config_cronjob_test.yaml index 3e65b5b..bcd20a2 100644 --- a/tests/quay_config_cronjob_test.yaml +++ b/tests/quay_config_cronjob_test.yaml @@ -15,13 +15,6 @@ tests: - equal: path: spec.jobTemplate.spec.template.spec.containers[0].name value: configure-quay - - it: does not render when configuration is disabled - set: - configJob: - enabled: false - asserts: - - hasDocuments: - count: 0 - it: does not render when quayConfig is disabled set: quayConfig: diff --git a/tests/quay_config_job_test.yaml b/tests/quay_config_job_test.yaml index bd720fa..1da2e95 100644 --- a/tests/quay_config_job_test.yaml +++ b/tests/quay_config_job_test.yaml @@ -45,13 +45,6 @@ tests: asserts: - notExists: path: spec.template.spec.initContainers - - it: does not render when configuration is disabled - set: - configJob: - enabled: false - asserts: - - hasDocuments: - count: 0 - it: does not render when quayConfig is disabled set: quayConfig: diff --git a/tests/quay_config_secret_test.yaml b/tests/quay_config_secret_test.yaml index c916e8f..c41b101 100644 --- a/tests/quay_config_secret_test.yaml +++ b/tests/quay_config_secret_test.yaml @@ -42,13 +42,6 @@ tests: - equal: path: spec.data[1].secretKey value: quay-user-password - - it: does not render when configuration is disabled - set: - configJob: - enabled: false - asserts: - - hasDocuments: - count: 0 - it: does not render when quayConfig is disabled set: quayConfig: diff --git a/values.yaml b/values.yaml index f52c823..ca97b41 100644 --- a/values.yaml +++ b/values.yaml @@ -86,8 +86,8 @@ secretStore: kind: ClusterSecretStore quayConfig: - # -- Create the configuration Job, CronJob, and credential Secret. - # Set false to leave users, organizations, and repositories unmanaged. + # -- Apply users, organizations, and repositories. Set false to skip + # the configuration Job, CronJob, ConfigMap, ExternalSecret, and RBAC. enabled: true credentials: # -- Vault (or other backend) path extracted into quay-config-credentials. @@ -138,8 +138,6 @@ consoleLink: href: "" configJob: - # -- Run the bootstrap Job and the reconciling CronJob. - enabled: true # -- Image with ansible-core, ansible-galaxy, oc, and cURL. # Same default as job.image. The container home is /pattern-home. image: quay.io/validatedpatterns/imperative-container:v1 From 6cb1f700f47694c589ebce626c5fa0372e635ba6 Mon Sep 17 00:00:00 2001 From: Martin Jackson Date: Mon, 5 Oct 2026 08:20:50 -0500 Subject: [PATCH 9/9] Add robot support --- README.md | 144 ++++++++++++++------------- README.md.gotmpl | 6 +- templates/_helpers.tpl | 50 +++++++++- tests/quay_config_playbook_test.yaml | 23 +++++ values.yaml | 14 ++- 5 files changed, 162 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 51cba5b..f8c79c9 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,15 @@ # quay + ![Version: 0.2.0](https://img.shields.io/badge/Version-0.2.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 3.9](https://img.shields.io/badge/AppVersion-3.9-informational?style=flat-square) + + Red Hat Quay Registry Resources + This chart is used to serve as the template for Validated Patterns Charts @@ -18,84 +22,88 @@ This chart is used to serve as the template for Validated Patterns Charts backend (`objectStorage.mode=mcg`). Set `objectStorage.mode=odf` to use an existing OpenShift Data Foundation StorageCluster instead. - An OpenShift console link points at the Quay route and embeds the Quay icon. -- Users, organizations, and repositories are applied by a Job and CronJob - using the `infra.quay_configuration` Ansible collection. Passwords are - projected with an ExternalSecret. +- Users, organizations, repositories, and robot accounts are applied by + a Job and CronJob using the `infra.quay_configuration` Ansible + collection. Passwords are projected with an ExternalSecret. - `quay.setup` and `quay_config` moved to `quayConfig`. **Homepage:** ## Maintainers -| Name | Email | Url | -| ---- | ------ | --- | -| Zero Trust Validated Patterns Team | | | +| Name | Email | Url | +| ---------------------------------- | ---------------------------- | --- | +| Zero Trust Validated Patterns Team | | | + ## Values -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| configJob.activeDeadlineSeconds | int | `1800` | Job activeDeadlineSeconds for the bootstrap Job and CronJob pods. | -| configJob.backoffLimit | int | `5` | | -| configJob.collectionVersion | string | `"2.8.1"` | infra.quay_configuration version passed to ansible-galaxy. | -| configJob.failedJobsHistoryLimit | int | `1` | | -| configJob.image | string | `"quay.io/validatedpatterns/imperative-container:v1"` | Image with ansible-core, ansible-galaxy, oc, and cURL. Same default as job.image. The container home is /pattern-home. | -| configJob.imagePullPolicy | string | `"Always"` | | -| configJob.installCollection | bool | `true` | Install infra.quay_configuration into an emptyDir before the playbook. Set false when configJob.image already contains the collection. | -| configJob.resources.limits.cpu | string | `"500m"` | | -| configJob.resources.limits.memory | string | `"512Mi"` | | -| configJob.resources.requests.cpu | string | `"50m"` | | -| configJob.resources.requests.memory | string | `"256Mi"` | | -| configJob.schedule | string | `"*/30 * * * *"` | Cron schedule for re-applying Quay configuration. | -| configJob.successfulJobsHistoryLimit | int | `1` | | -| configJob.validateCerts | bool | `false` | Verify the Quay route TLS certificate. In-cluster routes often use a private CA, so the default is false. | -| consoleLink.enabled | bool | `true` | Create an ApplicationMenu ConsoleLink for the Quay route. | -| consoleLink.href | string | `""` | Override the computed route URL. Empty builds the managed route from quay.name, quay.namespace, and global.clusterDomain. | -| consoleLink.name | string | `"quay"` | ConsoleLink metadata.name. | -| consoleLink.section | string | `"Red Hat applications"` | Application menu section. | -| consoleLink.text | string | `"Red Hat Quay"` | Menu text. | -| global.clusterDomain | string | `"example.com"` | OpenShift cluster base domain. Used in the console link when consoleLink.href is empty. The host is apps. plus this domain. | -| job.image | string | `"quay.io/validatedpatterns/imperative-container:v1"` | Image for the S3 credentials Job. The Validated Patterns imperative container provides oc. | -| job.resources.limits.cpu | string | `"500m"` | | -| job.resources.limits.memory | string | `"512Mi"` | | -| job.resources.requests.cpu | string | `"50m"` | | -| job.resources.requests.memory | string | `"128Mi"` | | -| objectStorage.mcg.backingStore.name | string | `"noobaa-default-backing-store"` | BackingStore that holds the gateway's persistent volumes. | -| objectStorage.mcg.bucketClass.name | string | `"noobaa-default-bucket-class"` | BucketClass used by the default NooBaa storage class. | -| objectStorage.mcg.bucketClass.placement.tiers[0].backingStores[0] | string | `"noobaa-default-backing-store"` | | -| objectStorage.mcg.dbSize | string | `"50Gi"` | Size of the NooBaa PostgreSQL volume. | -| objectStorage.mcg.namespace | string | `"openshift-storage"` | Namespace of the NooBaa system. Must match the ODF operator namespace. | -| objectStorage.mcg.pvPool.numVolumes | int | `1` | Number of persistent volumes in the backing store pool. | -| objectStorage.mcg.pvPool.resources.limits.cpu | string | `"1"` | | -| objectStorage.mcg.pvPool.resources.limits.memory | string | `"4Gi"` | | -| objectStorage.mcg.pvPool.resources.requests.cpu | string | `"800m"` | | -| objectStorage.mcg.pvPool.resources.requests.memory | string | `"800Mi"` | | -| objectStorage.mcg.pvPool.resources.requests.storage | string | `"50Gi"` | | -| objectStorage.mcg.system.name | string | `"noobaa"` | Name of the NooBaa custom resource. | -| objectStorage.mode | string | `"mcg"` | mcg deploys the standalone Multicloud Object Gateway. odf consumes an existing OpenShift Data Foundation StorageCluster. | -| objectStorage.objectBucketClaim.bucketName | string | `"quay-datastore"` | Prefix passed to generateBucketName. | -| objectStorage.objectBucketClaim.name | string | `"quay-bucket"` | ObjectBucketClaim name. The bound ConfigMap and Secret use this name. | -| objectStorage.objectBucketClaim.storageClass | string | `"openshift-storage.noobaa.io"` | StorageClass for the claim. Override for Ceph RGW, for example ocs-storagecluster-ceph-rgw. | -| quay.configBundleSecret.deploy | bool | `true` | | -| quay.configBundleSecret.name | string | `"quay-init-config-bundle-secret"` | Template secret. The S3 job copies it and fills storage placeholders. | -| quay.configBundleSecret.s3Name | string | `"quay-config-with-s3"` | Secret QuayRegistry reads after the S3 job fills credentials. | -| quay.name | string | `"quay-registry"` | Name of the QuayRegistry resource. The managed route is this name with -quay appended. | -| quay.namespace | string | `"quay-enterprise"` | Namespace for the Quay registry and its configuration jobs. | -| quay.storage.clairpostgres.size | string | `"50Gi"` | Persistent volume size for the Clair PostgreSQL database. | -| quay.storage.postgres.size | string | `"50Gi"` | Persistent volume size for the Quay PostgreSQL database. | -| quayConfig.credentials.key | string | `"secret/data/hub/quay-users"` | Vault (or other backend) path extracted into quay-config-credentials. | -| quayConfig.enabled | bool | `true` | Apply users, organizations, and repositories. Set false to skip the configuration Job, CronJob, ConfigMap, ExternalSecret, and RBAC. | -| quayConfig.organizations | list | `[]` | | -| quayConfig.repositories | list | `[]` | | -| quayConfig.users[0].email | string | `"quayadmin@example.com"` | | -| quayConfig.users[0].initialize | bool | `true` | | -| quayConfig.users[0].name | string | `"quayadmin"` | | -| quayConfig.users[0].passwordProperty | string | `"quay-admin-password"` | Property on credentials.key projected into the credentials Secret. | -| quayConfig.users[0].superuser | bool | `true` | | -| secretStore.kind | string | `"ClusterSecretStore"` | Kind of secretStore.name. | -| secretStore.name | string | `"vault-backend"` | SecretStore or ClusterSecretStore that holds Quay user passwords. | +| Key | Type | Default | Description | +| ----------------------------------------------------------------- | ------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| configJob.activeDeadlineSeconds | int | `1800` | Job activeDeadlineSeconds for the bootstrap Job and CronJob pods. | +| configJob.backoffLimit | int | `5` | | +| configJob.collectionVersion | string | `"2.8.1"` | infra.quay_configuration version passed to ansible-galaxy. | +| configJob.failedJobsHistoryLimit | int | `1` | | +| configJob.image | string | `"quay.io/validatedpatterns/imperative-container:v1"` | Image with ansible-core, ansible-galaxy, oc, and cURL. Same default as job.image. The container home is /pattern-home. | +| configJob.imagePullPolicy | string | `"Always"` | | +| configJob.installCollection | bool | `true` | Install infra.quay_configuration into an emptyDir before the playbook. Set false when configJob.image already contains the collection. | +| configJob.resources.limits.cpu | string | `"500m"` | | +| configJob.resources.limits.memory | string | `"512Mi"` | | +| configJob.resources.requests.cpu | string | `"50m"` | | +| configJob.resources.requests.memory | string | `"256Mi"` | | +| configJob.schedule | string | `"*/30 * * * *"` | Cron schedule for re-applying Quay configuration. | +| configJob.successfulJobsHistoryLimit | int | `1` | | +| configJob.validateCerts | bool | `false` | Verify the Quay route TLS certificate. In-cluster routes often use a private CA, so the default is false. | +| consoleLink.enabled | bool | `true` | Create an ApplicationMenu ConsoleLink for the Quay route. | +| consoleLink.href | string | `""` | Override the computed route URL. Empty builds the managed route from quay.name, quay.namespace, and global.clusterDomain. | +| consoleLink.name | string | `"quay"` | ConsoleLink metadata.name. | +| consoleLink.section | string | `"Red Hat applications"` | Application menu section. | +| consoleLink.text | string | `"Red Hat Quay"` | Menu text. | +| global.clusterDomain | string | `"example.com"` | OpenShift cluster base domain. Used in the console link when consoleLink.href is empty. The host is apps. plus this domain. | +| job.image | string | `"quay.io/validatedpatterns/imperative-container:v1"` | Image for the S3 credentials Job. The Validated Patterns imperative container provides oc. | +| job.resources.limits.cpu | string | `"500m"` | | +| job.resources.limits.memory | string | `"512Mi"` | | +| job.resources.requests.cpu | string | `"50m"` | | +| job.resources.requests.memory | string | `"128Mi"` | | +| objectStorage.mcg.backingStore.name | string | `"noobaa-default-backing-store"` | BackingStore that holds the gateway's persistent volumes. | +| objectStorage.mcg.bucketClass.name | string | `"noobaa-default-bucket-class"` | BucketClass used by the default NooBaa storage class. | +| objectStorage.mcg.bucketClass.placement.tiers[0].backingStores[0] | string | `"noobaa-default-backing-store"` | | +| objectStorage.mcg.dbSize | string | `"50Gi"` | Size of the NooBaa PostgreSQL volume. | +| objectStorage.mcg.namespace | string | `"openshift-storage"` | Namespace of the NooBaa system. Must match the ODF operator namespace. | +| objectStorage.mcg.pvPool.numVolumes | int | `1` | Number of persistent volumes in the backing store pool. | +| objectStorage.mcg.pvPool.resources.limits.cpu | string | `"1"` | | +| objectStorage.mcg.pvPool.resources.limits.memory | string | `"4Gi"` | | +| objectStorage.mcg.pvPool.resources.requests.cpu | string | `"800m"` | | +| objectStorage.mcg.pvPool.resources.requests.memory | string | `"800Mi"` | | +| objectStorage.mcg.pvPool.resources.requests.storage | string | `"50Gi"` | | +| objectStorage.mcg.system.name | string | `"noobaa"` | Name of the NooBaa custom resource. | +| objectStorage.mode | string | `"mcg"` | mcg deploys the standalone Multicloud Object Gateway. odf consumes an existing OpenShift Data Foundation StorageCluster. | +| objectStorage.objectBucketClaim.bucketName | string | `"quay-datastore"` | Prefix passed to generateBucketName. | +| objectStorage.objectBucketClaim.name | string | `"quay-bucket"` | ObjectBucketClaim name. The bound ConfigMap and Secret use this name. | +| objectStorage.objectBucketClaim.storageClass | string | `"openshift-storage.noobaa.io"` | StorageClass for the claim. Override for Ceph RGW, for example ocs-storagecluster-ceph-rgw. | +| quay.configBundleSecret.deploy | bool | `true` | | +| quay.configBundleSecret.name | string | `"quay-init-config-bundle-secret"` | Template secret. The S3 job copies it and fills storage placeholders. | +| quay.configBundleSecret.s3Name | string | `"quay-config-with-s3"` | Secret QuayRegistry reads after the S3 job fills credentials. | +| quay.name | string | `"quay-registry"` | Name of the QuayRegistry resource. The managed route is this name with -quay appended. | +| quay.namespace | string | `"quay-enterprise"` | Namespace for the Quay registry and its configuration jobs. | +| quay.storage.clairpostgres.size | string | `"50Gi"` | Persistent volume size for the Clair PostgreSQL database. | +| quay.storage.postgres.size | string | `"50Gi"` | Persistent volume size for the Quay PostgreSQL database. | +| quayConfig.credentials.key | string | `"secret/data/hub/quay-users"` | Vault (or other backend) path extracted into quay-config-credentials. | +| quayConfig.enabled | bool | `true` | Apply users, organizations, repositories, and robot accounts. Set false to skip the configuration Job, CronJob, ConfigMap, ExternalSecret, and RBAC. | +| quayConfig.organizations | list | `[]` | | +| quayConfig.repositories | list | `[]` | | +| quayConfig.robots | list | `[]` | Robot accounts created with infra.quay_configuration.quay_robot. | +| quayConfig.users[0].email | string | `"quayadmin@example.com"` | | +| quayConfig.users[0].initialize | bool | `true` | | +| quayConfig.users[0].name | string | `"quayadmin"` | | +| quayConfig.users[0].passwordProperty | string | `"quay-admin-password"` | Property on credentials.key projected into the credentials Secret. | +| quayConfig.users[0].superuser | bool | `true` | | +| secretStore.kind | string | `"ClusterSecretStore"` | Kind of secretStore.name. | +| secretStore.name | string | `"vault-backend"` | SecretStore or ClusterSecretStore that holds Quay user passwords. | + ----------------------------------------------- +--- + Autogenerated from chart metadata using [helm-docs v1.14.2](https://github.com/norwoodj/helm-docs/releases/v1.14.2) diff --git a/README.md.gotmpl b/README.md.gotmpl index a99d2a8..ac3209c 100644 --- a/README.md.gotmpl +++ b/README.md.gotmpl @@ -19,9 +19,9 @@ This chart is used to serve as the template for Validated Patterns Charts backend (`objectStorage.mode=mcg`). Set `objectStorage.mode=odf` to use an existing OpenShift Data Foundation StorageCluster instead. - An OpenShift console link points at the Quay route and embeds the Quay icon. -- Users, organizations, and repositories are applied by a Job and CronJob - using the `infra.quay_configuration` Ansible collection. Passwords are - projected with an ExternalSecret. +- Users, organizations, repositories, and robot accounts are applied by + a Job and CronJob using the `infra.quay_configuration` Ansible + collection. Passwords are projected with an ExternalSecret. - `quay.setup` and `quay_config` moved to `quayConfig`. {{ template "chart.homepageLine" . }} diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl index b4f3d68..57e6186 100644 --- a/templates/_helpers.tpl +++ b/templates/_helpers.tpl @@ -1,5 +1,5 @@ {{/* -Validate quayConfig.users, organizations, and repositories. +Validate quayConfig.users, organizations, repositories, and robots. Renders nothing. Fails the release on invalid input. */}} {{- define "quay.config.validate" -}} @@ -38,6 +38,33 @@ Renders nothing. Fails the release on invalid input. {{- fail (printf "repository name %q must be namespace/name" .name) }} {{- end }} {{- end }} +{{- range (.Values.quayConfig.robots | default list) }} +{{- if not .name }} +{{- fail "each quayConfig.robots entry needs a name" }} +{{- end }} +{{- $short := .name }} +{{- if contains "+" .name }} +{{- $parts := splitList "+" .name }} +{{- if ne (len $parts) 2 }} +{{- fail (printf "robot name %q must be namespace+shortname" .name) }} +{{- end }} +{{- if or (not (index $parts 0)) (not (index $parts 1)) }} +{{- fail (printf "robot name %q must be namespace+shortname" .name) }} +{{- end }} +{{- $short = index $parts 1 }} +{{- end }} +{{- if not (regexMatch "^[a-z][a-z0-9_-]+$" $short) }} +{{- fail (printf "robot short name %q is invalid" $short) }} +{{- end }} +{{- $robotName := .name }} +{{- if .federations }} +{{- range .federations }} +{{- if or (not .issuer) (not .subject) }} +{{- fail (printf "robot %s federations need issuer and subject" $robotName) }} +{{- end }} +{{- end }} +{{- end }} +{{- end }} {{- end -}} {{- define "quay.pod.securityContext" -}} @@ -132,6 +159,27 @@ mounted Secret and are read with a file lookup. quay_password: "{{`{{ admin_password }}`}}" validate_certs: "{{`{{ validate_certs | bool }}`}}" {{- end }} +{{- range (.Values.quayConfig.robots | default list) }} + - name: Ensure robot {{ .name }} is {{ .state | default "present" }} + infra.quay_configuration.quay_robot: + name: {{ .name | quote }} +{{- if .description }} + description: {{ .description | quote }} +{{- end }} +{{- if .federations }} + federations: +{{- range .federations }} + - issuer: {{ .issuer | quote }} + subject: {{ .subject | quote }} +{{- end }} + append: {{ .append | default true }} +{{- end }} + state: {{ .state | default "present" | quote }} + quay_host: "{{`{{ quay_host }}`}}" + quay_username: "{{`{{ admin_username }}`}}" + quay_password: "{{`{{ admin_password }}`}}" + validate_certs: "{{`{{ validate_certs | bool }}`}}" +{{- end }} {{- range .Values.quayConfig.repositories }} - name: Ensure repository {{ .name }} exists infra.quay_configuration.quay_repository: diff --git a/tests/quay_config_playbook_test.yaml b/tests/quay_config_playbook_test.yaml index 753960d..775b9b0 100644 --- a/tests/quay_config_playbook_test.yaml +++ b/tests/quay_config_playbook_test.yaml @@ -22,6 +22,12 @@ tests: repositories: - name: devel/example visibility: private + robots: + - name: devel+builder + description: CI robot for the devel organization + federations: + - issuer: https://issuer.example.com + subject: robot-subject asserts: - matchRegex: path: data["requirements.yml"] @@ -35,12 +41,29 @@ tests: - matchRegex: path: data["playbook.yml"] pattern: "devel/example" + - matchRegex: + path: data["playbook.yml"] + pattern: "quay_robot:" + - matchRegex: + path: data["playbook.yml"] + pattern: "devel\\+builder" + - matchRegex: + path: data["playbook.yml"] + pattern: "https://issuer.example.com" - matchRegex: path: data["install.sh"] pattern: "ansible-galaxy collection install" - matchRegex: path: data["run.sh"] pattern: "ANSIBLE_REMOTE_TMP" + - it: rejects a robot short name that starts with a digit + set: + quayConfig: + robots: + - name: devel+1bot + asserts: + - failedTemplate: + errorMessage: robot short name "1bot" is invalid - it: does not render when quayConfig is disabled set: quayConfig: diff --git a/values.yaml b/values.yaml index ca97b41..d153f9a 100644 --- a/values.yaml +++ b/values.yaml @@ -86,15 +86,18 @@ secretStore: kind: ClusterSecretStore quayConfig: - # -- Apply users, organizations, and repositories. Set false to skip - # the configuration Job, CronJob, ConfigMap, ExternalSecret, and RBAC. + # -- Apply users, organizations, repositories, and robot accounts. + # Set false to skip the configuration Job, CronJob, ConfigMap, + # ExternalSecret, and RBAC. enabled: true credentials: # -- Vault (or other backend) path extracted into quay-config-credentials. key: secret/data/hub/quay-users # Exactly one user must set initialize, and that user must be a superuser. # Organization names must be at least four characters. Repository names - # use the namespace/name form. + # use the namespace/name form. Robot names use namespace+shortname. + # The short name is lowercase, starts with a letter, and is at least + # two characters. Federations require Quay 3.13 or later. users: - name: quayadmin email: quayadmin@example.com @@ -104,6 +107,8 @@ quayConfig: initialize: true organizations: [] repositories: [] + # -- Robot accounts created with infra.quay_configuration.quay_robot. + robots: [] # users: # - name: quayadmin @@ -123,6 +128,9 @@ quayConfig: # repositories: # - name: devel/example # visibility: private +# robots: +# - name: devel+builder +# description: CI robot for the devel organization consoleLink: # -- Create an ApplicationMenu ConsoleLink for the Quay route.