From 49f1692e44e65e63c62425bac06ee37969b3a449 Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Sat, 8 Aug 2026 23:02:42 +0200 Subject: [PATCH] Deploy phpPgAdmin with maintained public image --- .gitea/workflows/deploy.yml | 105 ++++++++++++ .gitignore | 7 + Dockerfile | 22 --- README.md | 186 +++++++++++++++++++- ci-deployer.yaml | 88 ++++++++++ create-ci-kubeconfig.sh | 48 ++++++ install.sh | 20 +++ phppgadmin.yaml | 334 ++++++++++++++++++++++++++++++++++++ test.sh | 28 +++ 9 files changed, 814 insertions(+), 24 deletions(-) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore delete mode 100644 Dockerfile create mode 100644 ci-deployer.yaml create mode 100755 create-ci-kubeconfig.sh create mode 100755 install.sh create mode 100644 phppgadmin.yaml create mode 100755 test.sh diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..9d19b42 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,105 @@ +name: Validate and deploy phpPgAdmin + +on: + push: + pull_request: + +env: + KUBECONFORM_VERSION: v0.7.0 + KUBECTL_VERSION: v1.36.3 + KUBERNETES_API: https://host.containers.internal:6443 + KUBERNETES_TLS_SERVER_NAME: 192.168.0.2 + NAMESPACE: default + +jobs: + validate: + name: Validate manifests + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Validate scripts and Kubernetes resources + run: | + sh -n install.sh + bash -n create-ci-kubeconfig.sh + bash -n test.sh + docker run --rm --interactive \ + "ghcr.io/yannh/kubeconform:${KUBECONFORM_VERSION}@sha256:85dbef6b4b312b99133decc9c6fc9495e9fc5f92293d4ff3b7e1b30f5611823c" \ + -strict -ignore-missing-schemas -summary < phppgadmin.yaml + docker run --rm --interactive \ + "ghcr.io/yannh/kubeconform:${KUBECONFORM_VERSION}@sha256:85dbef6b4b312b99133decc9c6fc9495e9fc5f92293d4ff3b7e1b30f5611823c" \ + -strict -summary < ci-deployer.yaml + + deploy: + name: Deploy to Kubernetes + if: gitea.ref == 'refs/heads/main' + needs: validate + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Install kubectl + run: | + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/kubectl" \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/kubectl.sha256" \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256" + printf '%s %s\n' "$(cat "${RUNNER_TEMP}/kubectl.sha256")" "${RUNNER_TEMP}/kubectl" | sha256sum --check + chmod 0700 "${RUNNER_TEMP}/kubectl" + + - name: Configure Kubernetes access + env: + KUBE_CONFIG_BASE64: ${{ secrets.KUBE_CONFIG_BASE64 }} + run: | + test -n "${KUBE_CONFIG_BASE64}" + printf '%s' "${KUBE_CONFIG_BASE64}" | base64 --decode > "${RUNNER_TEMP}/kubeconfig" + chmod 0600 "${RUNNER_TEMP}/kubeconfig" + export KUBECONFIG="${RUNNER_TEMP}/kubeconfig" + "${RUNNER_TEMP}/kubectl" config set-cluster cluster \ + --server="${KUBERNETES_API}" \ + --tls-server-name="${KUBERNETES_TLS_SERVER_NAME}" + + - name: Apply and verify + env: + COMMIT_SHA: ${{ gitea.sha }} + run: | + export KUBECONFIG="${RUNNER_TEMP}/kubeconfig" + sed \ + "s|deployment.brunner.ninja/revision: manual|deployment.brunner.ninja/revision: ${COMMIT_SHA}|" \ + phppgadmin.yaml > "${RUNNER_TEMP}/phppgadmin.yaml" + grep -Fq "deployment.brunner.ninja/revision: ${COMMIT_SHA}" \ + "${RUNNER_TEMP}/phppgadmin.yaml" + "${RUNNER_TEMP}/kubectl" apply \ + --dry-run=server \ + --validate=false \ + --filename "${RUNNER_TEMP}/phppgadmin.yaml" + "${RUNNER_TEMP}/kubectl" apply \ + --validate=false \ + --filename "${RUNNER_TEMP}/phppgadmin.yaml" + + for attempt in {1..120}; do + IFS='|' read -r generation observed desired updated ready available unavailable image <<< "$( + "${RUNNER_TEMP}/kubectl" --namespace "${NAMESPACE}" \ + get deployment/phppgadmin \ + --output=jsonpath='{.metadata.generation}|{.status.observedGeneration}|{.spec.replicas}|{.status.updatedReplicas}|{.status.readyReplicas}|{.status.availableReplicas}|{.status.unavailableReplicas}|{.spec.template.spec.containers[0].image}' + )" + + echo "Rollout ${attempt}/120: generation ${observed}/${generation}, replicas ${updated}/${desired} updated, ${ready}/${desired} ready, ${available}/${desired} available, image ${image}" + if [[ "${observed}" == "${generation}" \ + && "${updated}" == "${desired}" \ + && "${ready}" == "${desired}" \ + && "${available}" == "${desired}" \ + && ( -z "${unavailable}" || "${unavailable}" == "0" ) ]]; then + echo "phpPgAdmin deployment successfully rolled out" + exit 0 + fi + + sleep 5 + done + + echo "phpPgAdmin rollout did not complete within ten minutes" >&2 + exit 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50434b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Never commit generated cluster or registry credentials. +kubeconfig +*.kubeconfig +.env + +# Local build output. +*.log diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index aeb5d16..0000000 --- a/Dockerfile +++ /dev/null @@ -1,22 +0,0 @@ -FROM registry.brunner.ninja/feedc0de/php-apache-postgres - -RUN pacman -Syu --noconfirm git sudo base-devel \ - && cd /tmp \ - && sudo -u http git clone https://aur.archlinux.org/phppgadmin.git \ - && cd phppgadmin \ - && sudo -u http makepkg -s \ - && pacman -U --noconfirm *.pkg.* \ - && ls \ - && cd .. \ - && rm -Rfv phppgadmin \ - && pacman -Rcnsu --noconfirm git sudo base-devel \ - && pacman -Sc --noconfirm \ - && rm -v /var/log/pacman.log /var/lib/pacman/sync/* - -RUN sed -i 's|^DocumentRoot.*|DocumentRoot "/usr/share/webapps/phppgadmin"|' /etc/httpd/conf/httpd.conf \ - && echo '' >> /etc/httpd/conf/httpd.conf \ - && echo ' DirectoryIndex index.php' >> /etc/httpd/conf/httpd.conf \ - && echo ' AllowOverride All' >> /etc/httpd/conf/httpd.conf \ - && echo ' Options FollowSymlinks' >> /etc/httpd/conf/httpd.conf \ - && echo ' Require all granted' >> /etc/httpd/conf/httpd.conf \ - && echo '' >> /etc/httpd/conf/httpd.conf diff --git a/README.md b/README.md index 979da08..b994d04 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,185 @@ -# phppgadmin-docker +# phpPgAdmin on Kubernetes -Simple docker image to run latest phppgadmin (provided by arch) \ No newline at end of file +Production deployment for the brunner.ninja phpPgAdmin service. The repository +uses a maintained public container image, a single plain Kubernetes manifest, +Traefik ingress, health probes, zero-unavailable rolling updates, and restricted +Gitea Actions deployment credentials. + +## Architecture + +- `tozd/phppgadmin` supplies phpPgAdmin, Nginx, PHP, and PostgreSQL client tools. +- The image is pinned to an immutable multi-platform manifest digest rather + than a mutable tag. +- `default/phppgadmin-config` mounts the complete multi-server configuration at + `/etc/phppgadmin/config.inc.php`. +- A ClusterIP Service exposes TCP port 80 to Traefik. +- `https://phppgadmin.brunner.ninja` redirects to the image's native + `/phppgadmin/` path. +- One replica uses `maxUnavailable: 0`, `maxSurge: 1`, and five seconds of + minimum readiness. The old pod remains available until the replacement has + passed its HTTP probe continuously for five seconds. +- CI places the Git commit in a pod-template annotation. This forces a safe + rollout when a ConfigMap-only change would otherwise leave a `subPath` mount + unchanged in the running container. +- Startup, readiness, and liveness probes request `/phppgadmin/` over HTTP. + +The deployment is stateless. phpPgAdmin does not store database passwords or +sessions on a PVC; users authenticate directly to the selected PostgreSQL +server. The ConfigMap lists server addresses but contains no credentials. + +## Why there is no Dockerfile + +This repository previously built phpPgAdmin from the Arch User Repository on +top of a private base image and pushed the result to the private Quay registry. +That duplicated public image storage and made every repository commit rebuild +an otherwise unchanged application. + +The current deployment instead uses the actively maintained public +[`tozd/phppgadmin`](https://gitlab.com/tozd/docker/phppgadmin) image. It supports +the same configuration-file mount used here. The pinned image contains +phpPgAdmin 7.14.7, which is also the current AUR package version at the time of +this repository conversion. + +The committed image is: + +```text +tozd/phppgadmin@sha256:4ee690768d54b0c057dbc4ed5c1baa152c1a5ef0ebd6eb12f1e310e545eb4489 +``` + +It was inspected and smoke-tested before deployment. Pinning prevents an +upstream rebuild from silently changing the running software. To upgrade, +inspect and test the new upstream digest, replace the image line in +`phppgadmin.yaml`, and merge the change through the normal deployment workflow. + +## Repository contents + +- `phppgadmin.yaml`: ConfigMap, Deployment, Service, Ingress, and Traefik root + redirect Middleware. +- `install.sh`: server-side dry-run, apply, and rollout verification. +- `test.sh`: local script and Kubernetes schema validation. +- `ci-deployer.yaml`: restricted Gitea Actions ServiceAccount, Role, binding, + and revocable token. +- `create-ci-kubeconfig.sh`: one-time CI identity bootstrap. +- `.gitea/workflows/deploy.yml`: validation on every push and pull request; + deployment after successful validation of `main`. + +There is no Helm chart, Kustomize overlay, Argo CD application, private image, +or container build pipeline. + +## Prerequisites + +- Kubernetes and kubectl. +- An amd64 worker node. The pinned upstream image currently publishes amd64. +- Traefik with the `traefik.io/v1alpha1` Middleware CRD and ingress class + `traefik`. +- The existing Traefik `websecure` entrypoint and default + `*.brunner.ninja` TLS certificate. +- Network connectivity from the pod to every PostgreSQL hostname listed in the + ConfigMap. + +Another cluster operator can reuse the manifest by changing the namespace, +database server list, ingress hostname, ingress class, and redirect Middleware. + +## Install manually + +With an administrator kubeconfig active, run: + +```sh +./install.sh +``` + +The script performs a server-side dry-run, applies all resources, and waits up +to ten minutes for the Deployment. Existing resource names are preserved, so +the Service keeps its ClusterIP and the change rolls out in place. + +Verify both application paths: + +```sh +curl -I https://phppgadmin.brunner.ninja/ +curl -I https://phppgadmin.brunner.ninja/phppgadmin/ +``` + +The first should redirect permanently and the second should return the +phpPgAdmin application. + +## Configure PostgreSQL servers + +Edit `config.inc.php` inside the ConfigMap in `phppgadmin.yaml`. Each server +entry contains a display name, service DNS name, port, SSL mode, and default +database. Do not put PostgreSQL usernames or passwords into the ConfigMap or +Git repository. + +The file is mounted using `subPath`, so Kubernetes does not update it inside an +already-running pod. The CI workflow replaces the manifest's `manual` revision +annotation with the Git commit SHA before applying it, which safely rolls the +pod for every merged configuration change. A manual `./install.sh` changes the +annotation back to `manual` and likewise triggers a rollout after a CI deploy. + +## Validate locally + +```sh +./test.sh +``` + +The test checks both shell scripts and validates the Kubernetes resources with +kubeconform. The Traefik Middleware is reported as skipped because kubeconform's +standard schema catalog does not contain cluster-specific CRDs; the Kubernetes +API validates it during the server-side dry-run. + +## Automatic deployment with Gitea Actions + +The workflow validates every push and pull request. A successful push to +`main` applies the complete `phppgadmin.yaml`, including ConfigMap, Deployment, +Service, Ingress, and Middleware, then polls the Deployment for up to ten +minutes. + +No image is built or pushed. The workflow requires only one Gitea Actions +secret: + +- `KUBE_CONFIG_BASE64`: the restricted phpPgAdmin deployment kubeconfig, + base64-encoded on one line. + +### One-time CI setup + +1. Run `./install.sh` with an administrator identity so every application + resource already exists. +2. Run: + + ```sh + ./create-ci-kubeconfig.sh + ``` + +3. Copy only the final base64 line into the Gitea repository Actions secret + named `KUBE_CONFIG_BASE64`. +4. Protect `main`, require successful validation before merging, and review + manifest and workflow changes. +5. Push a harmless README change and confirm the deployment job succeeds. + +The workflow matches the existing brunner.ninja runner: Kubernetes API URL +`https://host.containers.internal:6443`, with TLS server name `192.168.0.2`. +Other clusters must change those values. + +CI can get, patch, and update only these existing named objects: + +- ConfigMap `phppgadmin-config`; +- Deployment and Service `phppgadmin`; +- Ingress `phppgadmin`; +- Middleware `phppgadmin-root-redirect`. + +It cannot create or delete resources and cannot call the Kubernetes Secrets +API. Revoke it by deleting `default/phppgadmin-deployer-token` and the Gitea +secret. Rerunning the wrapper creates a replacement token. + +Kubernetes RBAC cannot constrain individual fields inside an allowed +Deployment patch. A malicious change merged into `main` could alter the pod +specification. Branch protection and review therefore remain part of the +security boundary. + +## Internet exposure + +This instance is reachable from the public internet and presents direct +PostgreSQL username/password authentication. Use long, unique database +passwords, restrictive PostgreSQL roles and `pg_hba.conf` rules, TLS for remote +database connections, and external rate limiting or access control where +appropriate. phpPgAdmin's `extra_login_security` remains enabled in the +committed configuration. diff --git a/ci-deployer.yaml b/ci-deployer.yaml new file mode 100644 index 0000000..1fd818f --- /dev/null +++ b/ci-deployer.yaml @@ -0,0 +1,88 @@ +# Bootstrap this file once with an administrator identity. CI can update only +# the existing phpPgAdmin resources and cannot call the Kubernetes Secrets API. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: phppgadmin-deployer + namespace: default +automountServiceAccountToken: false +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: phppgadmin-deployer + namespace: default +rules: + - apiGroups: + - "" + resources: + - configmaps + resourceNames: + - phppgadmin-config + verbs: + - get + - patch + - update + - apiGroups: + - "" + resources: + - services + resourceNames: + - phppgadmin + verbs: + - get + - patch + - update + - apiGroups: + - apps + resources: + - deployments + resourceNames: + - phppgadmin + verbs: + - get + - patch + - update + - apiGroups: + - networking.k8s.io + resources: + - ingresses + resourceNames: + - phppgadmin + verbs: + - get + - patch + - update + - apiGroups: + - traefik.io + resources: + - middlewares + resourceNames: + - phppgadmin-root-redirect + verbs: + - get + - patch + - update +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: phppgadmin-deployer + namespace: default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: phppgadmin-deployer +subjects: + - kind: ServiceAccount + name: phppgadmin-deployer + namespace: default +--- +apiVersion: v1 +kind: Secret +metadata: + name: phppgadmin-deployer-token + namespace: default + annotations: + kubernetes.io/service-account.name: phppgadmin-deployer +type: kubernetes.io/service-account-token diff --git a/create-ci-kubeconfig.sh b/create-ci-kubeconfig.sh new file mode 100755 index 0000000..a51df07 --- /dev/null +++ b/create-ci-kubeconfig.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash + +set -euo pipefail + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +namespace=${NAMESPACE:-default} +service_account=${SERVICE_ACCOUNT:-phppgadmin-deployer} +secret=${TOKEN_SECRET:-phppgadmin-deployer-token} + +kubectl apply --filename "${project_dir}/ci-deployer.yaml" >&2 + +for attempt in {1..30}; do + if kubectl --namespace "${namespace}" get secret "${secret}" \ + --output=jsonpath='{.data.token}' 2>/dev/null | grep -q .; then + break + fi + if [[ "${attempt}" == 30 ]]; then + echo "Timed out waiting for the service-account token" >&2 + exit 1 + fi + sleep 1 +done + +workdir=$(mktemp --directory) +trap 'rm -rf "${workdir}"' EXIT + +server=$(kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}') +kubectl --namespace "${namespace}" get secret "${secret}" \ + --output=jsonpath='{.data.ca\.crt}' | base64 --decode > "${workdir}/ca.crt" +token=$(kubectl --namespace "${namespace}" get secret "${secret}" \ + --output=jsonpath='{.data.token}' | base64 --decode) + +export KUBECONFIG="${workdir}/config" +kubectl config set-cluster cluster \ + --server="${server}" \ + --certificate-authority="${workdir}/ca.crt" \ + --embed-certs=true >/dev/null +kubectl config set-credentials "${service_account}" --token="${token}" >/dev/null +kubectl config set-context phppgadmin \ + --cluster=cluster \ + --user="${service_account}" \ + --namespace="${namespace}" >/dev/null +kubectl config use-context phppgadmin >/dev/null + +echo "Store the following single line as the Gitea Actions secret KUBE_CONFIG_BASE64." >&2 +echo "Treat it as a password; do not commit it." >&2 +base64 --wrap=0 "${KUBECONFIG}" +printf '\n' diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..59f621f --- /dev/null +++ b/install.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +set -eu + +namespace=default +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +if ! command -v kubectl >/dev/null 2>&1; then + echo "Required command not found: kubectl" >&2 + exit 1 +fi + +kubectl apply --dry-run=server --filename "$script_dir/phppgadmin.yaml" +kubectl apply --filename "$script_dir/phppgadmin.yaml" +kubectl rollout status deployment/phppgadmin \ + --namespace "$namespace" \ + --timeout=10m + +echo +echo "phpPgAdmin is available at https://phppgadmin.brunner.ninja." diff --git a/phppgadmin.yaml b/phppgadmin.yaml new file mode 100644 index 0000000..d430530 --- /dev/null +++ b/phppgadmin.yaml @@ -0,0 +1,334 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: phppgadmin-config + namespace: default + labels: + app.kubernetes.io/name: phppgadmin +data: + config.inc.php: | + /dev/null 2>&1; then + kubeconform -strict -ignore-missing-schemas -summary "${manifest}" + elif command -v docker >/dev/null 2>&1; then + docker run --rm --interactive \ + ghcr.io/yannh/kubeconform:v0.7.0@sha256:85dbef6b4b312b99133decc9c6fc9495e9fc5f92293d4ff3b7e1b30f5611823c \ + -strict -ignore-missing-schemas -summary < "${manifest}" + else + echo "Required command not found: install kubeconform or Docker" >&2 + exit 1 + fi +} + +validate_file "${project_dir}/phppgadmin.yaml" +validate_file "${project_dir}/ci-deployer.yaml" + +echo "phpPgAdmin scripts and Kubernetes resources are valid."