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..f8c79c9 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, 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 @@ -28,28 +39,68 @@ 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.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. | diff --git a/README.md.gotmpl b/README.md.gotmpl index c426f84..ac3209c 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, 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" . }} {{ 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..d0ad030 --- /dev/null +++ b/files/quay-config-run.sh @@ -0,0 +1,61 @@ +#!/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:-/pattern-home}" + +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}" +# 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}" \ + -e "validate_certs=${validate_certs}" diff --git a/files/quay-icon.png b/files/quay-icon.png new file mode 100644 index 0000000..8ad64a1 Binary files /dev/null and b/files/quay-icon.png differ 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..57e6186 --- /dev/null +++ b/templates/_helpers.tpl @@ -0,0 +1,280 @@ +{{/* +Validate quayConfig.users, organizations, repositories, and robots. +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 }} +{{- 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" -}} +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.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: + 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: /pattern-home/collections + - name: HOME + value: /pattern-home + command: + - /bin/bash + - /quay-config/install.sh + volumeMounts: + - name: tmp + mountPath: /tmp + - name: work + mountPath: /pattern-home + - 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: /pattern-home/collections + - name: HOME_DIR + value: /pattern-home + - name: ANSIBLE_REMOTE_TMP + value: /pattern-home/.ansible/tmp + command: + - /bin/bash + - /quay-config/run.sh + volumeMounts: + - name: tmp + mountPath: /tmp + - name: work + mountPath: /pattern-home + - 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..a6821b8 100644 --- a/templates/quay-config-bundle-secret.yaml +++ b/templates/quay-config-bundle-secret.yaml @@ -1,10 +1,13 @@ +{{- if .Values.quayConfig.enabled }} +{{- include "quay.config.validate" . -}} +{{- end }} 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 +15,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 +35,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..37d9adb --- /dev/null +++ b/templates/quay-config-configmap.yaml @@ -0,0 +1,21 @@ +{{- if .Values.quayConfig.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..535a137 --- /dev/null +++ b/templates/quay-config-cronjob.yaml @@ -0,0 +1,21 @@ +{{- if .Values.quayConfig.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..9c9e1b9 --- /dev/null +++ b/templates/quay-config-externalsecret.yaml @@ -0,0 +1,24 @@ +{{- if .Values.quayConfig.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..7e7279c --- /dev/null +++ b/templates/quay-config-job.yaml @@ -0,0 +1,15 @@ +{{- if .Values.quayConfig.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..1e385e9 --- /dev/null +++ b/templates/quay-config-rbac.yaml @@ -0,0 +1,37 @@ +{{- if .Values.quayConfig.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..53fa04e --- /dev/null +++ b/tests/config_bundle_test.yaml @@ -0,0 +1,29 @@ +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" + - 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/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..bcd20a2 --- /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 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 new file mode 100644 index 0000000..1da2e95 --- /dev/null +++ b/tests/quay_config_job_test.yaml @@ -0,0 +1,54 @@ +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 + - 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: + installCollection: false + asserts: + - notExists: + path: spec.template.spec.initContainers + - 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 new file mode 100644 index 0000000..775b9b0 --- /dev/null +++ b/tests/quay_config_playbook_test.yaml @@ -0,0 +1,73 @@ +suite: Ansible configuration playbook +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 + 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"] + 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["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: + enabled: false + asserts: + - hasDocuments: + count: 0 diff --git a/tests/quay_config_secret_test.yaml b/tests/quay_config_secret_test.yaml new file mode 100644 index 0000000..c41b101 --- /dev/null +++ b/tests/quay_config_secret_test.yaml @@ -0,0 +1,51 @@ +suite: Ansible configuration external secret +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 + - 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 quayConfig is disabled + set: + quayConfig: + 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..d153f9a 100644 --- a/values.yaml +++ b/values.yaml @@ -1,43 +1,174 @@ +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: image-registry.openshift-image-registry.svc:5000/openshift/cli + # -- Image for the S3 credentials Job. The Validated Patterns imperative + # container provides oc. + image: quay.io/validatedpatterns/imperative-container:v1 resources: requests: cpu: 50m memory: 128Mi limits: cpu: 500m - memory: 256Mi - -quay_config: - org: - name: devel - email: devel@myorg.com - repo: example + memory: 512Mi -# 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: + # -- 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. 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 + # -- Property on credentials.key projected into the credentials Secret. + passwordProperty: quay-admin-password + superuser: true + initialize: true + organizations: [] + repositories: [] + # -- Robot accounts created with infra.quay_configuration.quay_robot. + robots: [] + +# 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 +# robots: +# - name: devel+builder +# description: CI robot for the devel organization + +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: + # -- 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 + 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