diff --git a/.gitea/workflows/chart.yml b/.gitea/workflows/chart.yml new file mode 100644 index 0000000..a100051 --- /dev/null +++ b/.gitea/workflows/chart.yml @@ -0,0 +1,72 @@ +name: Validate, publish and promote Quay chart +on: + push: + pull_request: +jobs: + chart: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: azure/setup-helm@v4 + with: + version: v4.3.0 + - name: Validate chart + run: | + python3 -m venv "${RUNNER_TEMP}/chart-test" + "${RUNNER_TEMP}/chart-test/bin/pip" install PyYAML + helm dependency build chart/quay + PATH="${RUNNER_TEMP}/chart-test/bin:${PATH}" bash chart/test.sh + - name: Select immutable chart version + if: gitea.ref == 'refs/heads/main' + run: printf 'CHART_VERSION=%s\n' "$(./scripts/chart-version.sh)" >> "${GITHUB_ENV}" + - name: Publish chart + if: gitea.ref == 'refs/heads/main' + env: + PACKAGE_USERNAME: ${{ secrets.PACKAGE_USERNAME }} + PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + run: | + set -euo pipefail + test -n "${PACKAGE_USERNAME}" && test -n "${PACKAGE_TOKEN}" + mkdir -p .chart-packages + helm package chart/quay --destination .chart-packages --version "${CHART_VERSION}" + curl --fail --silent --show-error --user "${PACKAGE_USERNAME}:${PACKAGE_TOKEN}" \ + --request POST --upload-file ".chart-packages/quay-${CHART_VERSION}.tgz" \ + https://code.brunner.ninja/api/packages/feedc0de/helm/api/charts + - name: Check current main + id: current-main + if: gitea.ref == 'refs/heads/main' + env: + COMMIT_SHA: ${{ gitea.sha }} + run: | + git fetch --no-tags origin main + if test "$(git rev-parse FETCH_HEAD)" = "${COMMIT_SHA}"; then + echo 'promote=true' >> "${GITEA_OUTPUT}" + else + echo 'promote=false' >> "${GITEA_OUTPUT}" + fi + - uses: actions/checkout@v4 + if: steps.current-main.outputs.promote == 'true' + with: + repository: feedc0de/homelab-gitops + ref: main + token: ${{ secrets.GITOPS_TOKEN }} + path: homelab-gitops + - name: Promote chart + if: steps.current-main.outputs.promote == 'true' + working-directory: homelab-gitops + run: | + set -euo pipefail + ./scripts/update-chart-version.sh quay "${CHART_VERSION}" + git config user.name feedc0de + git config user.email feedc0de@users.noreply.code.brunner.ninja + git add bootstrap/quay.yaml + if git diff --cached --quiet; then exit 0; fi + git commit -m "Deploy Quay chart ${CHART_VERSION}" + for attempt in 1 2 3; do + git push origin HEAD:main && exit 0 + test "${attempt}" = 3 && exit 1 + git fetch origin main + git rebase origin/main + done diff --git a/.gitignore b/.gitignore index be56e69..8d5e675 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ config.yaml *.kubeconfig .env +chart/quay/charts/ +.chart-packages/ diff --git a/README.md b/README.md index ba3195e..7dce919 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,15 @@ # Quay on Kubernetes -Plain Kubernetes deployment for `https://registry.brunner.ninja` in the -`default` namespace. Quay updates are deliberately simple: change both matching -`quay.io/projectquay/quay` image references in `quay.yaml`, commit, and push to -`main`. Gitea Actions performs the Kubernetes update. +This repository now maintains a [reusable Helm chart](chart/README.md) for +Project Quay. It declares Quay's registry and repository mirror, optional +PostgreSQL and Valkey subcharts, and the Service and Ingress. The chart uses +the official Quay image and is published to `https://brunner.ninja/charts`. -The deployment uses plain YAML. There is no Helm chart, Operator, Kustomize, -Argo CD, or custom container build. +The homelab migration is staged in +[`homelab-gitops/apps/quay`](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/quay). +Until Argo CD has adopted and verified the live Deployments, the older plain +manifest and direct-deploy workflow below remain the rollback path. Do not +change `quay.yaml` during the database cutover. ## Persistent data diff --git a/chart/README.md b/chart/README.md new file mode 100644 index 0000000..7464090 --- /dev/null +++ b/chart/README.md @@ -0,0 +1,44 @@ +# Quay Helm chart + +This chart runs the official Project Quay image and owns the registry +Deployment, repository-mirror Deployment, Service, Ingress, and probes. It can +also deploy PostgreSQL and Valkey through optional Bitnami subcharts. It does +not create a second container image. + +An S3-compatible object store and bucket are required. Provision them outside +this chart, then create a private `config.yaml` using +[`config.yaml.example`](config.yaml.example). The file includes the database +URI, S3 credentials, Redis credentials, and two persistent Quay encryption +keys. Keep all of those out of Helm values and public Git. Store the file in a +Secret in the release namespace: + +```sh +kubectl -n default create secret generic quay-config --from-file=config.yaml +``` + +Set `quay.configSecretName` to that Secret name. For an existing installation, +preserve its `SECRET_KEY`, `DATABASE_SECRET_KEY`, bucket, and database records. +The chart never creates or deletes S3 objects or buckets. + +The optional PostgreSQL subchart is enabled with `postgresql.enabled=true`. +Use a durable storage class and stable `postgresql.auth.existingSecret`; the +`database-password` key must match the password inside Quay's `DB_URI`. The +chart also accepts an external PostgreSQL server with the subchart disabled. +Likewise, `valkey.enabled=true` installs a cache; keep the Redis hosts and +password in `config.yaml` aligned with either that subchart or your external +cache. Both dependencies are independently optional. + +Published releases are available from the indexed Helm repository: + +```sh +helm repo add brunner.ninja https://brunner.ninja/charts +helm repo update brunner.ninja +helm upgrade --install quay brunner.ninja/quay --namespace default \ + --values values.yaml --wait --timeout 15m +``` + +For local development, run `helm dependency build chart/quay` and +`bash chart/test.sh`. Chart package versions derive from the actual Quay Git +release tag with `git describe`; `Chart.yaml.appVersion` is the tested upstream +image version. Homelab-specific values are in +[`homelab-gitops/apps/quay`](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/quay). diff --git a/chart/config.yaml.example b/chart/config.yaml.example new file mode 100644 index 0000000..8fceba2 --- /dev/null +++ b/chart/config.yaml.example @@ -0,0 +1,30 @@ +# Create a private config.yaml from this template and store it as the +# config.yaml key in the Secret selected by quay.configSecretName. +SETUP_COMPLETE: true +SERVER_HOSTNAME: quay.example.org +PREFERRED_URL_SCHEME: https +EXTERNAL_TLS_TERMINATION: true +AUTHENTICATION_TYPE: Database +SECRET_KEY: REPLACE_WITH_STABLE_RANDOM_SECRET_KEY +DATABASE_SECRET_KEY: REPLACE_WITH_STABLE_RANDOM_DATABASE_SECRET_KEY +DB_URI: postgresql://quay:REPLACE_WITH_DATABASE_PASSWORD@quay-postgresql.default.svc.cluster.local/quay +BUILDLOGS_REDIS: + host: quay-valkey-primary.default.svc.cluster.local + port: 6379 + password: REPLACE_WITH_VALKEY_PASSWORD +USER_EVENTS_REDIS: + host: quay-valkey-primary.default.svc.cluster.local + port: 6379 + password: REPLACE_WITH_VALKEY_PASSWORD +DISTRIBUTED_STORAGE_CONFIG: + default: + - RadosGWStorage + - access_key: REPLACE_WITH_S3_ACCESS_KEY + secret_key: REPLACE_WITH_S3_SECRET_KEY + bucket_name: REPLACE_WITH_EXISTING_BUCKET + hostname: REPLACE_WITH_S3_HOST + is_secure: true + storage_path: / +DISTRIBUTED_STORAGE_DEFAULT_LOCATIONS: [] +DISTRIBUTED_STORAGE_PREFERENCE: + - default diff --git a/chart/quay/Chart.lock b/chart/quay/Chart.lock new file mode 100644 index 0000000..56fb1f2 --- /dev/null +++ b/chart/quay/Chart.lock @@ -0,0 +1,9 @@ +dependencies: +- name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 18.12.4 +- name: valkey + repository: https://charts.bitnami.com/bitnami + version: 6.3.4 +digest: sha256:ae4c73e405ad893c310aeac3f35de37f7c8cbbd28a7b3089d973a680048990cc +generated: "2026-10-20T08:46:49.462960335+02:00" diff --git a/chart/quay/Chart.yaml b/chart/quay/Chart.yaml new file mode 100644 index 0000000..b797204 --- /dev/null +++ b/chart/quay/Chart.yaml @@ -0,0 +1,15 @@ +apiVersion: v2 +name: quay +description: Project Quay registry with optional PostgreSQL and Valkey +type: application +version: 0.0.0 +appVersion: 3.18.0 +dependencies: + - name: postgresql + version: 18.12.4 + repository: https://charts.bitnami.com/bitnami + condition: postgresql.enabled + - name: valkey + version: 6.3.4 + repository: https://charts.bitnami.com/bitnami + condition: valkey.enabled diff --git a/chart/quay/templates/deployment.yaml b/chart/quay/templates/deployment.yaml new file mode 100644 index 0000000..424f21f --- /dev/null +++ b/chart/quay/templates/deployment.yaml @@ -0,0 +1,70 @@ +{{- if .Values.quay.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: quay + labels: + app: quay + app.kubernetes.io/name: quay + app.kubernetes.io/component: registry +spec: + replicas: {{ .Values.quay.replicas }} + progressDeadlineSeconds: 900 + strategy: + type: Recreate + selector: + matchLabels: + app: quay + template: + metadata: + labels: + app: quay + app.kubernetes.io/name: quay + app.kubernetes.io/component: registry + spec: + containers: + - name: quay + image: "{{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }}" + env: + - name: DEBUGLOG + value: {{ .Values.quay.debugLog | quote }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + startupProbe: + httpGet: + path: /health/instance + port: http + failureThreshold: 60 + periodSeconds: 5 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: /health/endtoend + port: http + failureThreshold: 3 + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /health/instance + port: http + failureThreshold: 3 + periodSeconds: 30 + timeoutSeconds: 5 +{{- with .Values.resources }} + resources: +{{ toYaml . | indent 12 }} +{{- end }} + volumeMounts: + - name: quay-config + mountPath: /conf/stack/config.yaml + subPath: config.yaml + readOnly: true + volumes: + - name: quay-config + secret: + secretName: {{ .Values.quay.configSecretName | quote }} + defaultMode: 0440 +{{- end }} diff --git a/chart/quay/templates/ingress.yaml b/chart/quay/templates/ingress.yaml new file mode 100644 index 0000000..94f0594 --- /dev/null +++ b/chart/quay/templates/ingress.yaml @@ -0,0 +1,29 @@ +{{- if and .Values.quay.enabled .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: quay +{{- with .Values.ingress.annotations }} + annotations: +{{ toYaml . | indent 4 }} +{{- end }} +spec: +{{- with .Values.ingress.className }} + ingressClassName: {{ . | quote }} +{{- end }} +{{- with .Values.ingress.tls }} + tls: +{{ toYaml . | indent 4 }} +{{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: quay + port: + name: http +{{- end }} diff --git a/chart/quay/templates/repomirror.yaml b/chart/quay/templates/repomirror.yaml new file mode 100644 index 0000000..dc031ad --- /dev/null +++ b/chart/quay/templates/repomirror.yaml @@ -0,0 +1,45 @@ +{{- if and .Values.quay.enabled .Values.repomirror.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: quay-repomirror + labels: + app: quay-repomirror + app.kubernetes.io/name: quay + app.kubernetes.io/component: repository-mirror +spec: + replicas: {{ .Values.repomirror.replicas }} + strategy: + type: Recreate + selector: + matchLabels: + app: quay-repomirror + template: + metadata: + labels: + app: quay-repomirror + app.kubernetes.io/name: quay + app.kubernetes.io/component: repository-mirror + spec: + containers: + - name: quay-repomirror + image: "{{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }}" + args: [repomirror] + env: + - name: DEBUGLOG + value: {{ .Values.quay.debugLog | quote }} +{{- with .Values.repomirrorResources }} + resources: +{{ toYaml . | indent 12 }} +{{- end }} + volumeMounts: + - name: quay-config + mountPath: /conf/stack/config.yaml + subPath: config.yaml + readOnly: true + volumes: + - name: quay-config + secret: + secretName: {{ .Values.quay.configSecretName | quote }} + defaultMode: 0440 +{{- end }} diff --git a/chart/quay/templates/service.yaml b/chart/quay/templates/service.yaml new file mode 100644 index 0000000..a24785b --- /dev/null +++ b/chart/quay/templates/service.yaml @@ -0,0 +1,17 @@ +{{- if .Values.quay.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: quay + labels: + app.kubernetes.io/name: quay +spec: + type: ClusterIP + ports: + - name: http + port: {{ .Values.service.port }} + protocol: TCP + targetPort: 8080 + selector: + app: quay +{{- end }} diff --git a/chart/quay/values.schema.json b/chart/quay/values.schema.json new file mode 100644 index 0000000..09e152f --- /dev/null +++ b/chart/quay/values.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "image": {"type": "object", "properties": {"repository": {"type": "string", "minLength": 1}, "tag": {"type": "string"}}}, + "quay": {"type": "object", "required": ["enabled", "configSecretName"], "properties": {"enabled": {"type": "boolean"}, "replicas": {"type": "integer", "minimum": 0}, "configSecretName": {"type": "string", "minLength": 1}, "debugLog": {"type": "boolean"}}}, + "repomirror": {"type": "object", "properties": {"enabled": {"type": "boolean"}, "replicas": {"type": "integer", "minimum": 0}}}, + "service": {"type": "object", "properties": {"port": {"type": "integer", "minimum": 1, "maximum": 65535}}}, + "ingress": {"type": "object", "properties": {"enabled": {"type": "boolean"}, "host": {"type": "string", "minLength": 1}, "className": {"type": "string"}, "annotations": {"type": "object", "additionalProperties": {"type": "string"}}, "tls": {"type": "array"}}}, + "resources": {"type": "object"}, + "repomirrorResources": {"type": "object"} + } +} diff --git a/chart/quay/values.yaml b/chart/quay/values.yaml new file mode 100644 index 0000000..ed1ddae --- /dev/null +++ b/chart/quay/values.yaml @@ -0,0 +1,35 @@ +image: + repository: quay.io/projectquay/quay + tag: "" + +quay: + enabled: true + replicas: 1 + configSecretName: quay-config + debugLog: false + +repomirror: + enabled: true + replicas: 1 + +service: + port: 80 + +ingress: + enabled: false + className: "" + host: quay.example.org + annotations: {} + tls: [] + +resources: {} +repomirrorResources: {} + +postgresql: + enabled: false + auth: + username: quay + database: quay + +valkey: + enabled: false diff --git a/chart/test.sh b/chart/test.sh new file mode 100755 index 0000000..ee4d202 --- /dev/null +++ b/chart/test.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +chart_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/quay" && pwd) +helm lint "$chart_dir" --strict + +python3 - "$chart_dir" <<'PY' +import subprocess +import sys +import yaml + +chart = sys.argv[1] + +def render(*args): + output = subprocess.check_output( + ['helm', 'template', 'quay', chart, '--namespace', 'default', *args], text=True + ) + return {(doc['kind'], doc['metadata']['name']): doc for doc in yaml.safe_load_all(output) if doc} + +active = render() +assert {('Deployment', 'quay'), ('Deployment', 'quay-repomirror'), + ('Service', 'quay')} <= active.keys() +registry = active['Deployment', 'quay'] +mirror = active['Deployment', 'quay-repomirror'] +assert registry['spec']['selector']['matchLabels'] == {'app': 'quay'} +assert mirror['spec']['selector']['matchLabels'] == {'app': 'quay-repomirror'} +assert registry['spec']['strategy']['type'] == mirror['spec']['strategy']['type'] == 'Recreate' +assert registry['spec']['template']['spec']['containers'][0]['image'] == mirror['spec']['template']['spec']['containers'][0]['image'] +assert registry['spec']['template']['spec']['volumes'][0]['secret']['secretName'] == 'quay-config' +assert active['Service', 'quay']['spec']['selector'] == {'app': 'quay'} + +staged = render('--set', 'quay.enabled=false', '--set', 'postgresql.enabled=true') +assert ('StatefulSet', 'quay-postgresql') in staged +assert not any(name == 'quay' or name == 'quay-repomirror' for kind, name in staged) +assert ('Service', 'quay-postgresql') in staged +PY diff --git a/scripts/chart-version.sh b/scripts/chart-version.sh new file mode 100755 index 0000000..75296a2 --- /dev/null +++ b/scripts/chart-version.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +description=$(git -C "$project_dir" describe --tags --long --match 'v[0-9]*' --dirty) +case "$description" in + *-dirty) echo 'Refusing to package a dirty tree.' >&2; exit 1 ;; +esac +version=${description#v} +case "$version" in + *-0-g*) version=${version%%-0-g*} ;; +esac +printf '%s\n' "$version"