Merge pull request #27 from jk-cluster/feat/install-test-pipeline

CI: install-test stage for changed charts in the PR pipeline (k3d)
This commit is contained in:
jklein authored and GitHub committed 2026-08-21 21:16:12 +00:00
commit f2c0b01f80
35 files changed
+1716 -1

No files matched your search

+34
View File
@@ -67,3 +67,37 @@ jobs:
else
echo "Version bump OK: $BASE_VERSION -> $NEW_VERSION"
fi
install-test:
name: "Install-test ${{ matrix.chart }}"
needs: [detect-changed-charts, build-chart]
if: needs.detect-changed-charts.outputs.any-chart-changed == 'true'
runs-on: ubuntu-latest
# Stabilization phase (issue #22): the job reports failures but does not
# block the PR. Promote to a required check (drop continue-on-error) once
# it has proven stable across several chart PRs.
continue-on-error: true
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
chart: ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }}
env:
K3D_VERSION: v5.9.0
K3S_IMAGE: docker.io/rancher/k3s:v1.35.5-k3s1
steps:
- name: "Checkout Repository"
uses: actions/checkout@v4
- name: "Install Helm"
uses: azure/setup-helm@v4
- name: "Install k3d"
run: curl -sSfL https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | TAG="$K3D_VERSION" bash
- name: "Create k3d cluster"
run: k3d cluster create install-test --image "$K3S_IMAGE" --wait --timeout 180s
# Excluded charts (see ci/excluded-charts.txt) are skipped inside the
# script with their documented reason.
- name: "Install chart and wait for readiness"
run: ci/run-install-test.sh "${{ matrix.chart }}"
- name: "Delete k3d cluster"
if: always()
run: k3d cluster delete install-test || true
+15 -1
View File
@@ -36,7 +36,21 @@ Collection of some self-made helm-charts. Each top-level directory is a standalo
- `publish-<chart>.yml` — one thin workflow per chart; triggers on pushes to `main` touching `<chart>/**` (or manually) and calls the reusable workflow below. **A push to a chart directory on `main` publishes that chart**, so the chart version must be bumped in the same change.
- `publish-helm-chart.yml` — reusable workflow: `helm package`, then `helm push` to the private JK-Registry. Registry credentials come from 1Password Connect (`OP_CONNECT_TOKEN` secret, items under `op://GitHub-Actions/`).
- `change-app-version.yml` — manually dispatched app-version bump; the chart must be listed in its `chart-name` choice list.
- `validate-charts.yml` — PR pipeline: detects which charts a PR touches and, for exactly those, runs `helm lint --strict`, `helm package`, and a chart-version bump check (`template-chart` is exempt from the version check). PRs without chart changes skip the build matrix.
- `validate-charts.yml` — PR pipeline: detects which charts a PR touches and, for exactly those, runs `helm lint --strict`, `helm package`, and a chart-version bump check (`template-chart` is exempt from the version check). PRs without chart changes skip the build matrix. After the build stage, an **install-test** job (same changed-charts matrix) installs each changed chart into an ephemeral [k3d](https://k3d.io) cluster on the runner and requires every workload to become Ready — see below.
### Install-test (PR pipeline stage 2)
For every chart a PR changes, `ci/run-install-test.sh <chart>` runs against a throwaway k3d cluster: it applies the stub `OnePasswordItem` CRD plus the chart's fixtures, `helm install`s the chart with its CI values, and waits for all Deployments/StatefulSets to roll out (per-chart timeout). Since probes and security hardening are active on all workloads, "Ready" means the app actually answers. On failure the job dumps pod descriptions, events and container logs.
Fixtures live at the **repo root** under `ci/` (deliberately *not* inside the chart directories, so they are neither packaged into the chart tgz nor trigger the publish workflows on merge):
- `ci/common/` — fixtures applied for every chart (schema-less stub CRD for `OnePasswordItem`)
- `ci/<chart>/test-values.yaml` — values for the CI install (required for every installable chart)
- `ci/<chart>/fixtures.yaml` — dummy secrets with in-cluster connection data, throwaway infrastructure (postgres/redis/dex), static hostPath PVs/StorageClasses where the chart pins a storage class or needs RWX, and seed jobs (e.g. `synapse generate`)
- `ci/<chart>/settings.env` — optional overrides (e.g. `INSTALL_TIMEOUT` for slow starters)
- `ci/excluded-charts.txt` — charts that cannot run in CI, each with a justification. Currently: `satisfactory` (multi-GB image + 10GB+ Steam download), `teamspeak-server` (the TS6 v6.0.0-beta8 image crashes with "The default license has expired" until the appVersion is bumped), `template-chart` (scaffold with placeholder image)
Fixture images avoid Docker Hub where possible (`public.ecr.aws`/`ghcr.io`) to dodge runner rate limits. The job currently runs with `continue-on-error: true` (stabilization phase); it will be promoted to a required check once it has proven stable across several chart PRs. When adding a new chart, add its `ci/<chart>/` fixtures (or an entry in `ci/excluded-charts.txt`) — the install-test fails if both are missing. The script also runs locally against any existing cluster: `k3d cluster create t && ci/run-install-test.sh <chart> && k3d cluster delete t`.
## Creating a new chart
+3
View File
@@ -0,0 +1,3 @@
# apache-tika needs no extra values for a CI install: no secrets, no ingress,
# no persistence.
{}
+82
View File
@@ -0,0 +1,82 @@
# Fixtures for the calibre-web-automated install-test: the cwa secret the
# 1Password operator would otherwise materialize and static hostPath PVs
# (the consume PVC is ReadWriteMany, which local-path cannot provision).
# This chart names resources <chart>-<release>-..., release name is ci.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ci-static
provisioner: kubernetes.io/no-provisioner
---
apiVersion: batch/v1
kind: Job
metadata:
name: node-setup
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: setup
image: public.ecr.aws/docker/library/busybox:1.36
command:
- sh
- -c
- >
mkdir -p /pv/cwa/data /pv/cwa/consume &&
chmod -R 0777 /pv/cwa
volumeMounts:
- name: pv
mountPath: /pv
volumes:
- name: pv
hostPath:
path: /var/lib/ci-pv
type: DirectoryOrCreate
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: ci-cwa-data
spec:
capacity:
storage: 2Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: ci-static
claimRef:
namespace: default
name: calibre-web-automated-ci-cwa-pvc
hostPath:
path: /var/lib/ci-pv/cwa/data
type: DirectoryOrCreate
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: ci-cwa-consume
spec:
capacity:
storage: 1Gi
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: ci-static
claimRef:
namespace: default
name: calibre-web-automated-ci-cwa-consume-pvc
hostPath:
path: /var/lib/ci-pv/cwa/consume
type: DirectoryOrCreate
---
apiVersion: v1
kind: Secret
metadata:
name: calibre-web-automated-ci-cwa-secret
stringData:
hardcover-api-token: ci-dummy-token
# Mounted as /config/.config/calibre/customize.py.json (calibre plugin
# registry); an empty JSON object is a valid file.
calibre-plugins: "{}"
+2
View File
@@ -0,0 +1,2 @@
# First start unpacks the calibre library scaffold onto the volume.
INSTALL_TIMEOUT=600
+12
View File
@@ -0,0 +1,12 @@
secrets:
cwaSecretRef: op://ci/cwa
cwa:
# Both PVCs share this class and the consume PVC is RWX, which k3d's
# local-path provisioner cannot serve - the fixtures pre-bind static
# hostPath PVs of class ci-static instead.
storageClass: ci-static
storage: 2Gi
ingress:
domain: cwa.ci.local
+24
View File
@@ -0,0 +1,24 @@
# Schema-less stub CRD for the 1Password operator's OnePasswordItem CR.
# The charts render OnePasswordItem resources; in CI there is no operator, so
# this stub lets them apply cleanly while the actual Kubernetes Secrets are
# hand-created per chart in ci/<chart>/fixtures.yaml.
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: onepassworditems.onepassword.com
spec:
group: onepassword.com
names:
kind: OnePasswordItem
listKind: OnePasswordItemList
plural: onepassworditems
singular: onepassworditem
scope: Namespaced
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
x-kubernetes-preserve-unknown-fields: true
+5
View File
@@ -0,0 +1,5 @@
# cyberchef: stateless, no secrets. Only the ingress host is required
# (clusterIssuer already has a default). The Ingress just has to apply -
# there is no ingress-nginx/cert-manager in the CI cluster.
ingress:
domain: cyberchef.ci.local
+5
View File
@@ -0,0 +1,5 @@
# Charts excluded from the PR install-test, one per line: <chart> <reason>.
# ci/run-install-test.sh skips these with the given reason.
satisfactory the game-server image is multiple GB and Steam downloads 10GB+ game files on first start - impractical on a CI runner
teamspeak-server the TS6 v6.0.0-beta8 image crashes on start ("The default license has expired. Please use the latest server version.", re-verified 2026-08-21); nothing to test until the appVersion is bumped
template-chart scaffold with placeholder image "registry/image:xxx", never published
+3
View File
@@ -0,0 +1,3 @@
# gotenberg needs no extra values for a CI install: no secrets, no ingress,
# no persistence.
{}
+19
View File
@@ -0,0 +1,19 @@
# Fixtures for the homeassistant install-test: the config secret the
# 1Password operator would otherwise materialize (release name: ci). The
# chart mounts the three keys as configuration.yaml, http-config.yaml and
# recorder-config.yaml under /config.
apiVersion: v1
kind: Secret
metadata:
name: ci-homeassistant-config-secret
stringData:
config: |
default_config:
http: !include http-config.yaml
recorder: !include recorder-config.yaml
http-config: |
use_x_forwarded_for: true
trusted_proxies:
- 10.42.0.0/16
recorder-config: |
db_url: sqlite:////config/home-assistant_v2.db
+2
View File
@@ -0,0 +1,2 @@
# Home Assistant's first boot installs/initializes integrations.
INSTALL_TIMEOUT=600
+9
View File
@@ -0,0 +1,9 @@
secrets:
configSecretRef: op://ci/homeassistant-config
core:
storage:
# k3d's default dynamic provisioner (the values default "default" does
# not exist there). Home Assistant starts as root, so writability of the
# dynamically provisioned volume is not an issue.
storageClass: local-path
+95
View File
@@ -0,0 +1,95 @@
# Fixtures for the matrix-synapse install-test. Synapse needs a signing key
# and a log config on its PVC before it starts, so the PVC is backed by a
# pre-bound hostPath PV and a seed Job runs "synapse generate" into that
# directory first (same pattern as the k3d verification for PRs #21/#26).
# The chart mounts homeserver.yaml from the config secret; the static config
# below points at the generated key/log-config paths. Release name: ci.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ci-static
provisioner: kubernetes.io/no-provisioner
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: ci-matrix-synapse-data
spec:
capacity:
storage: 2Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: ci-static
claimRef:
namespace: default
name: ci-matrix-synapse-pvc
hostPath:
path: /var/lib/ci-pv/matrix-synapse
type: DirectoryOrCreate
---
apiVersion: batch/v1
kind: Job
metadata:
name: synapse-seed
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: generate
# Same image the chart deploys (appVersion of matrix-synapse).
image: ghcr.io/element-hq/synapse:v1.149.1
args: ["generate"]
env:
- name: SYNAPSE_SERVER_NAME
value: synapse.ci.local
- name: SYNAPSE_REPORT_STATS
value: "no"
# Match the UID/GID the chart runs synapse with, so the generated
# signing key (mode 0600) is readable by the server container.
- name: UID
value: "1000"
- name: GID
value: "1000"
volumeMounts:
- name: pv
mountPath: /data
subPath: matrix-synapse-data
volumes:
- name: pv
hostPath:
path: /var/lib/ci-pv/matrix-synapse
type: DirectoryOrCreate
---
apiVersion: v1
kind: Secret
metadata:
name: ci-matrix-synapse-config-secret
stringData:
homeserver.yaml: |
server_name: "synapse.ci.local"
pid_file: /data/homeserver.pid
listeners:
- port: 8008
tls: false
type: http
x_forwarded: true
resources:
- names: [client, federation]
compress: false
database:
name: sqlite3
args:
database: /data/homeserver.db
log_config: "/data/synapse.ci.local.log.config"
media_store_path: /media_store
signing_key_path: "/data/synapse.ci.local.signing.key"
registration_shared_secret: "ci-registration-shared-secret"
macaroon_secret_key: "ci-macaroon-secret-key"
form_secret: "ci-form-secret"
report_stats: false
trusted_key_servers:
- server_name: "matrix.org"
suppress_key_server_warning: true
+2
View File
@@ -0,0 +1,2 @@
# Synapse first start (SQLite schema setup) plus the config seed job.
INSTALL_TIMEOUT=600
+14
View File
@@ -0,0 +1,14 @@
matrix:
serverName: synapse.ci.local
configSecretRef: op://ci/matrix-synapse-config
delegation:
enabled: true
serverUrl: ci.local
storage:
amount: 2Gi
storageClass: ci-static
ingress:
clusterIssuer: ci
domain: matrix.ci.local
+184
View File
@@ -0,0 +1,184 @@
# Fixtures for the outline install-test: throwaway postgres + redis, a dex
# OIDC provider (outline fetches the issuer's discovery document at boot),
# and the secrets the 1Password operator would otherwise materialize
# (release name: ci). Outline's /_health probe checks Postgres AND Redis.
apiVersion: v1
kind: Secret
metadata:
name: ci-outline-secret
stringData:
# Outline requires 64-char hex secrets.
outline-secret-key: 5f4dcc3b5aa765d61d8327deb882cf995f4dcc3b5aa765d61d8327deb882cf99
outline-utils-secret: 9c1185a5c5e9fc54612808977ee8f5489c1185a5c5e9fc54612808977ee8f548
discovery-url: http://dex.default.svc.cluster.local:5556/dex
client-id: ci-client
client-secret: ci-client-secret
---
apiVersion: v1
kind: Secret
metadata:
name: ci-outline-db-secret
stringData:
connection-url: postgres://ci:ci@postgres:5432/outline
---
apiVersion: v1
kind: Secret
metadata:
name: ci-outline-redis-secret
stringData:
connection-url: redis://:cipass@redis:6379
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
labels:
app: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: public.ecr.aws/docker/library/postgres:16-alpine
env:
- name: POSTGRES_USER
value: ci
- name: POSTGRES_PASSWORD
value: ci
- name: POSTGRES_DB
value: outline
ports:
- containerPort: 5432
readinessProbe:
exec:
command: ["pg_isready", "-U", "ci"]
periodSeconds: 5
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumes:
- name: data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
labels:
app: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: public.ecr.aws/docker/library/redis:7-alpine
args: ["redis-server", "--requirepass", "cipass"]
ports:
- containerPort: 6379
readinessProbe:
tcpSocket:
port: 6379
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
---
apiVersion: v1
kind: ConfigMap
metadata:
name: dex-config
data:
config.yaml: |
issuer: http://dex.default.svc.cluster.local:5556/dex
storage:
type: memory
web:
http: 0.0.0.0:5556
oauth2:
skipApprovalScreen: true
staticClients:
- id: ci-client
secret: ci-client-secret
name: CI Client
redirectURIs:
- https://outline.ci.local/auth/oidc.callback
enablePasswordDB: true
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: dex
labels:
app: dex
spec:
replicas: 1
selector:
matchLabels:
app: dex
template:
metadata:
labels:
app: dex
spec:
containers:
- name: dex
image: ghcr.io/dexidp/dex:v2.41.1
command: ["dex", "serve", "/etc/dex/cfg/config.yaml"]
ports:
- containerPort: 5556
readinessProbe:
httpGet:
path: /dex/healthz
port: 5556
periodSeconds: 5
volumeMounts:
- name: config
mountPath: /etc/dex/cfg
volumes:
- name: config
configMap:
name: dex-config
---
apiVersion: v1
kind: Service
metadata:
name: dex
spec:
selector:
app: dex
ports:
- port: 5556
targetPort: 5556
+7
View File
@@ -0,0 +1,7 @@
secrets:
configSecretRef: op://ci/outline-config
redisSecretRef: op://ci/outline-redis
dbSecretRef: op://ci/outline-db
ingress:
domain: outline.ci.local
+193
View File
@@ -0,0 +1,193 @@
# Fixtures for the paperless-ngx install-test: throwaway postgres + redis,
# the secrets the 1Password operator would otherwise materialize (release
# name: ci), and storage plumbing. The chart hardcodes storageClassName
# "longhorn" (RWX for the consume PVC), which does not exist in k3d: a
# no-provisioner StorageClass named "longhorn" plus pre-bound hostPath PVs
# stand in for it. Paperless runs as uid 1000 with a read-only rootfs and
# hostPath volumes do not support fsGroup, so a node-setup Job pre-creates
# the exact subPath directories world-writable.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: longhorn
provisioner: kubernetes.io/no-provisioner
---
apiVersion: batch/v1
kind: Job
metadata:
name: node-setup
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: setup
image: public.ecr.aws/docker/library/busybox:1.36
command:
- sh
- -c
- >
mkdir -p /pv/paperless-ngx/data/data /pv/paperless-ngx/data/media
/pv/paperless-ngx/data/export /pv/paperless-ngx/consume &&
chmod -R 0777 /pv/paperless-ngx
volumeMounts:
- name: pv
mountPath: /pv
volumes:
- name: pv
hostPath:
path: /var/lib/ci-pv
type: DirectoryOrCreate
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: ci-paperless-data
spec:
capacity:
storage: 20Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: longhorn
claimRef:
namespace: default
name: ci-paperless-ngx-volume-claim
hostPath:
path: /var/lib/ci-pv/paperless-ngx/data
type: DirectoryOrCreate
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: ci-paperless-consume
spec:
capacity:
storage: 1Gi
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: longhorn
claimRef:
namespace: default
name: ci-paperless-ngx-consume-volume-claim
hostPath:
path: /var/lib/ci-pv/paperless-ngx/consume
type: DirectoryOrCreate
---
apiVersion: v1
kind: Secret
metadata:
name: ci-paperless-ngx-redis-secret
stringData:
redis-connection-url: redis://:cipass@redis:6379
key-prefix: ci
---
apiVersion: v1
kind: Secret
metadata:
name: ci-paperless-ngx-db-secret
stringData:
server: postgres
port: "5432"
database: paperless
username: ci
password: ci
---
apiVersion: v1
kind: Secret
metadata:
name: ci-paperless-ngx-secret
stringData:
username: admin
password: ci-admin-password
paperless-secret-key: paperless-ci-secret-key-0123456789abcdef0123456789abcdef
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
labels:
app: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: public.ecr.aws/docker/library/postgres:16-alpine
env:
- name: POSTGRES_USER
value: ci
- name: POSTGRES_PASSWORD
value: ci
- name: POSTGRES_DB
value: paperless
ports:
- containerPort: 5432
readinessProbe:
exec:
command: ["pg_isready", "-U", "ci"]
periodSeconds: 5
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumes:
- name: data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
labels:
app: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: public.ecr.aws/docker/library/redis:7-alpine
args: ["redis-server", "--requirepass", "cipass"]
ports:
- containerPort: 6379
readinessProbe:
tcpSocket:
port: 6379
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
+2
View File
@@ -0,0 +1,2 @@
# Paperless runs database migrations and an index check on first start.
INSTALL_TIMEOUT=600
+10
View File
@@ -0,0 +1,10 @@
secrets:
redisRef: op://ci/paperless-redis
databaseRef: op://ci/paperless-db
paperlessRef: op://ci/paperless
ingress:
domain: paperless.ci.local
# smbConnection and oidc stay disabled (defaults): the SMB CSI driver and a
# real OIDC login flow are out of scope for the CI install-test.
+269
View File
@@ -0,0 +1,269 @@
# Fixtures for the patchmon install-test: throwaway postgres + redis, a dex
# OIDC provider, the secrets the 1Password operator would otherwise
# materialize (release name: ci), a pre-bound world-writable hostPath PV for
# the assets volume (the non-root backend writes agent assets at boot;
# hostPath does not support fsGroup), and a Service alias "backend": the
# frontend image's nginx template resolves an upstream host literally named
# "backend" at startup (found during the probes verification of PR #21).
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ci-static
provisioner: kubernetes.io/no-provisioner
---
apiVersion: batch/v1
kind: Job
metadata:
name: node-setup
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: setup
image: public.ecr.aws/docker/library/busybox:1.36
command:
- sh
- -c
- >
mkdir -p /pv/patchmon/patchmon-assets /pv/patchmon/patchmon-agents &&
chmod -R 0777 /pv/patchmon
volumeMounts:
- name: pv
mountPath: /pv
volumes:
- name: pv
hostPath:
path: /var/lib/ci-pv
type: DirectoryOrCreate
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: ci-patchmon-data
spec:
capacity:
storage: 10Gi
accessModes:
# The assets PVC is shared by backend and frontend as ReadWriteMany.
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: ci-static
claimRef:
namespace: default
name: ci-patchmon-pvc
hostPath:
path: /var/lib/ci-pv/patchmon
type: DirectoryOrCreate
---
apiVersion: v1
kind: Service
metadata:
name: backend
spec:
selector:
app: ci-patchmon-backend
ports:
- name: http
port: 80
targetPort: 3001
- name: direct
port: 3001
targetPort: 3001
---
apiVersion: v1
kind: Secret
metadata:
name: ci-patchmon-postgres-secret
stringData:
database-url: postgresql://ci:ci@postgres:5432/patchmon
---
apiVersion: v1
kind: Secret
metadata:
name: ci-patchmon-redis-secret
stringData:
host: redis
port: "6379"
username: default
password: cipass
db: "0"
---
apiVersion: v1
kind: Secret
metadata:
name: ci-patchmon-patchmon-secret
stringData:
jwt-secret: patchmon-ci-jwt-secret-0123456789abcdef0123456789abcdef
encryption-key: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
session-secret: patchmon-ci-session-secret-0123456789abcdef0123456789abcdef
---
apiVersion: v1
kind: Secret
metadata:
name: ci-patchmon-oidc-secret
stringData:
issuer-url: http://dex.default.svc.cluster.local:5556/dex
client-id: ci-client
client-secret: ci-client-secret
redirect-uri: https://patchmon.ci.local/auth/callback
requested-scopes: openid profile email
button-text: CI SSO
user-group-name: users
admin-group-name: admins
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
labels:
app: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: public.ecr.aws/docker/library/postgres:16-alpine
env:
- name: POSTGRES_USER
value: ci
- name: POSTGRES_PASSWORD
value: ci
- name: POSTGRES_DB
value: patchmon
ports:
- containerPort: 5432
readinessProbe:
exec:
command: ["pg_isready", "-U", "ci"]
periodSeconds: 5
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumes:
- name: data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
labels:
app: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: public.ecr.aws/docker/library/redis:7-alpine
args: ["redis-server", "--requirepass", "cipass"]
ports:
- containerPort: 6379
readinessProbe:
tcpSocket:
port: 6379
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
---
apiVersion: v1
kind: ConfigMap
metadata:
name: dex-config
data:
config.yaml: |
issuer: http://dex.default.svc.cluster.local:5556/dex
storage:
type: memory
web:
http: 0.0.0.0:5556
oauth2:
skipApprovalScreen: true
staticClients:
- id: ci-client
secret: ci-client-secret
name: CI Client
redirectURIs:
- https://patchmon.ci.local/auth/callback
enablePasswordDB: true
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: dex
labels:
app: dex
spec:
replicas: 1
selector:
matchLabels:
app: dex
template:
metadata:
labels:
app: dex
spec:
containers:
- name: dex
image: ghcr.io/dexidp/dex:v2.41.1
command: ["dex", "serve", "/etc/dex/cfg/config.yaml"]
ports:
- containerPort: 5556
readinessProbe:
httpGet:
path: /dex/healthz
port: 5556
periodSeconds: 5
volumeMounts:
- name: config
mountPath: /etc/dex/cfg
volumes:
- name: config
configMap:
name: dex-config
---
apiVersion: v1
kind: Service
metadata:
name: dex
spec:
selector:
app: dex
ports:
- port: 5556
targetPort: 5556
+19
View File
@@ -0,0 +1,19 @@
secrets:
redisSecretRef: op://ci/patchmon-redis
postgresSecretRef: op://ci/patchmon-postgres
oidcSecretRef: op://ci/patchmon-oidc
patchmonSecretRef: op://ci/patchmon
ingress:
clusterIssuer: ci
domain: patchmon.ci.local
patchmon:
storage:
# Point the RWX assets PVC at the fixture StorageClass so the pre-created
# world-writable hostPath PV is used (local-path cannot provision RWX, and
# the non-root backend must write its assets dir at boot). Note: the PVC
# template guards on .storage.storageClass but renders .storage.class,
# so both keys must be set.
storageClass: ci-static
class: ci-static
+143
View File
@@ -0,0 +1,143 @@
# Fixtures for the rallly install-test: throwaway postgres, a dex OIDC
# provider (the OIDC discovery URL must answer) and the secrets the
# 1Password operator would otherwise materialize (release name: ci).
apiVersion: v1
kind: Secret
metadata:
name: ci-rallly-secret
stringData:
secret-key: rallly-ci-secret-key-0123456789abcdef0123456789abcdef
initial-admin-email: ci@example.org
---
apiVersion: v1
kind: Secret
metadata:
name: ci-rallly-database-secret
stringData:
database-url: postgres://ci:ci@postgres:5432/rallly
---
apiVersion: v1
kind: Secret
metadata:
name: ci-rallly-oidc-secret
stringData:
discovery-url: http://dex.default.svc.cluster.local:5556/dex/.well-known/openid-configuration
client-id: ci-client
client-secret: ci-client-secret
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
labels:
app: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: public.ecr.aws/docker/library/postgres:16-alpine
env:
- name: POSTGRES_USER
value: ci
- name: POSTGRES_PASSWORD
value: ci
- name: POSTGRES_DB
value: rallly
ports:
- containerPort: 5432
readinessProbe:
exec:
command: ["pg_isready", "-U", "ci"]
periodSeconds: 5
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumes:
- name: data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
---
apiVersion: v1
kind: ConfigMap
metadata:
name: dex-config
data:
config.yaml: |
issuer: http://dex.default.svc.cluster.local:5556/dex
storage:
type: memory
web:
http: 0.0.0.0:5556
oauth2:
skipApprovalScreen: true
staticClients:
- id: ci-client
secret: ci-client-secret
name: CI Client
redirectURIs:
- https://rallly.ci.local/api/auth/callback/oidc
enablePasswordDB: true
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: dex
labels:
app: dex
spec:
replicas: 1
selector:
matchLabels:
app: dex
template:
metadata:
labels:
app: dex
spec:
containers:
- name: dex
image: ghcr.io/dexidp/dex:v2.41.1
command: ["dex", "serve", "/etc/dex/cfg/config.yaml"]
ports:
- containerPort: 5556
readinessProbe:
httpGet:
path: /dex/healthz
port: 5556
periodSeconds: 5
volumeMounts:
- name: config
mountPath: /etc/dex/cfg
volumes:
- name: config
configMap:
name: dex-config
---
apiVersion: v1
kind: Service
metadata:
name: dex
spec:
selector:
app: dex
ports:
- port: 5556
targetPort: 5556
+12
View File
@@ -0,0 +1,12 @@
secrets:
ralllySecretRef: op://ci/rallly
oidcSecretRef: op://ci/rallly-oidc
databaseSecretRef: op://ci/rallly-db
# smtp stays disabled (default), so no smtpSecretRef is needed.
rallly:
supportEmail: ci@example.org
ingress:
clusterIssuer: ci
domain: rallly.ci.local
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Install-test harness: installs one chart into the cluster of the current
# kubectl context (an ephemeral k3d cluster in CI) using only the committed
# fixtures under ci/<chart>/ and requires every workload to become Ready.
#
# Usage: ci/run-install-test.sh <chart-name>
#
# Layout (repo root):
# ci/common/ fixtures applied for every chart (OnePasswordItem stub CRD)
# ci/<chart>/test-values.yaml values for the CI install (required)
# ci/<chart>/fixtures.yaml dummy secrets / throwaway infra / static PVs (optional)
# ci/<chart>/settings.env optional overrides, e.g. INSTALL_TIMEOUT=600
# ci/excluded-charts.txt charts that cannot run in CI, with reasons
set -euo pipefail
CHART="${1:?usage: $0 <chart-name>}"
NAMESPACE="default"
RELEASE="ci"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CI_DIR="$ROOT/ci"
# Per-chart override via ci/<chart>/settings.env (INSTALL_TIMEOUT in seconds).
INSTALL_TIMEOUT=300
FIXTURE_JOB_TIMEOUT=600
if [ -f "$CI_DIR/$CHART/settings.env" ]; then
# shellcheck disable=SC1090
source "$CI_DIR/$CHART/settings.env"
fi
# --- Exclusion list ---------------------------------------------------------
if [ -f "$CI_DIR/excluded-charts.txt" ]; then
REASON="$(grep -E "^$CHART([[:space:]]|$)" "$CI_DIR/excluded-charts.txt" | sed -E "s/^$CHART[[:space:]]*//" || true)"
if [ -n "$REASON" ]; then
echo "SKIP: chart '$CHART' is excluded from the install-test: $REASON"
exit 0
fi
fi
if [ ! -d "$ROOT/$CHART" ] || [ ! -f "$ROOT/$CHART/Chart.yaml" ]; then
echo "ERROR: '$CHART' is not a chart directory" >&2
exit 1
fi
if [ ! -f "$CI_DIR/$CHART/test-values.yaml" ]; then
echo "ERROR: missing ci/$CHART/test-values.yaml — every installable chart needs CI fixtures." >&2
echo "Add test values (and fixtures.yaml if the app needs secrets/infra), or add the chart" >&2
echo "to ci/excluded-charts.txt with a justification." >&2
exit 1
fi
# --- Failure diagnostics ----------------------------------------------------
diagnose() {
echo "=================== INSTALL-TEST FAILED: DIAGNOSTICS ==================="
echo "--- pods ---"
kubectl -n "$NAMESPACE" get pods -o wide || true
echo "--- events (most recent last) ---"
kubectl -n "$NAMESPACE" get events --sort-by=.metadata.creationTimestamp | tail -60 || true
echo "--- describe pods ---"
kubectl -n "$NAMESPACE" describe pods || true
echo "--- container logs ---"
for POD in $(kubectl -n "$NAMESPACE" get pods -o name); do
echo "--- logs $POD ---"
kubectl -n "$NAMESPACE" logs "$POD" --all-containers --tail=150 --prefix || true
kubectl -n "$NAMESPACE" logs "$POD" --all-containers --tail=50 --previous --prefix 2>/dev/null || true
done
echo "========================================================================"
}
trap 'RC=$?; if [ $RC -ne 0 ]; then diagnose; fi; exit $RC' EXIT
# --- 1. Apply fixtures ------------------------------------------------------
echo "::group::Apply fixtures"
kubectl apply -f "$CI_DIR/common/"
if [ -f "$CI_DIR/$CHART/fixtures.yaml" ]; then
kubectl apply -f "$CI_DIR/$CHART/fixtures.yaml"
fi
echo "::endgroup::"
# --- 2. Wait for fixture jobs (node setup, data seeding) and infra ----------
echo "::group::Wait for fixture jobs and infrastructure"
JOBS="$(kubectl -n "$NAMESPACE" get jobs -o name 2>/dev/null || true)"
for JOB in $JOBS; do
echo "Waiting for $JOB to complete..."
kubectl -n "$NAMESPACE" wait --for=condition=complete "$JOB" --timeout="${FIXTURE_JOB_TIMEOUT}s"
done
for RES in $(kubectl -n "$NAMESPACE" get deploy,sts -o name 2>/dev/null || true); do
echo "Waiting for fixture $RES..."
kubectl -n "$NAMESPACE" rollout status "$RES" --timeout="${FIXTURE_JOB_TIMEOUT}s"
done
echo "::endgroup::"
# --- 3. Install the chart ---------------------------------------------------
echo "::group::helm install"
helm install "$RELEASE" "$ROOT/$CHART" \
--namespace "$NAMESPACE" \
-f "$CI_DIR/$CHART/test-values.yaml"
echo "::endgroup::"
# --- 4. Require every workload to become Ready ------------------------------
echo "::group::Wait for workloads (timeout ${INSTALL_TIMEOUT}s each)"
WORKLOADS="$(kubectl -n "$NAMESPACE" get deploy,sts -o name)"
if [ -z "$WORKLOADS" ]; then
echo "ERROR: the release created no Deployments/StatefulSets" >&2
exit 1
fi
for RES in $WORKLOADS; do
echo "Waiting for $RES..."
kubectl -n "$NAMESPACE" rollout status "$RES" --timeout="${INSTALL_TIMEOUT}s"
done
echo "::endgroup::"
echo "--- final state ---"
kubectl -n "$NAMESPACE" get pods -o wide
echo "OK: chart '$CHART' installed and all workloads are Ready."
+10
View File
@@ -0,0 +1,10 @@
# Fixtures for the samba install-test: the users secret the 1Password
# operator would otherwise materialize (release name: ci). users.conf uses
# the dockur/samba "user:password" format.
apiVersion: v1
kind: Secret
metadata:
name: ci-samba-users-secret
stringData:
users.conf: |
ciuser:ci-password
+10
View File
@@ -0,0 +1,10 @@
secrets:
sambaUsersRef: op://ci/samba-users
samba:
storageClass: local-path
shares:
- name: ci-share
validUsers: ciuser
description: CI test share
size: 1Gi
+141
View File
@@ -0,0 +1,141 @@
# Fixtures for the urlaubsverwaltung install-test: throwaway postgres, a dex
# OIDC provider (Spring performs OIDC discovery against the issuer at boot)
# and the secrets the 1Password operator would otherwise materialize.
# Note: this chart names secrets <chart>-<release>-..., release name is ci.
apiVersion: v1
kind: Secret
metadata:
name: urlaubsverwaltung-ci-uv-db-secret
stringData:
jdbc-url: jdbc:postgresql://postgres:5432/urlaubsverwaltung
username: ci
password: ci
---
apiVersion: v1
kind: Secret
metadata:
name: urlaubsverwaltung-ci-uv-oidc-secret
stringData:
client-id: ci-client
client-secret: ci-client-secret
issuer-name: CI
redirect-uri: https://uv.ci.local/login/oauth2/code/default
issuer-uri: http://dex.default.svc.cluster.local:5556/dex
jwt-issuer-uri: http://dex.default.svc.cluster.local:5556/dex
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
labels:
app: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: public.ecr.aws/docker/library/postgres:16-alpine
env:
- name: POSTGRES_USER
value: ci
- name: POSTGRES_PASSWORD
value: ci
- name: POSTGRES_DB
value: urlaubsverwaltung
ports:
- containerPort: 5432
readinessProbe:
exec:
command: ["pg_isready", "-U", "ci"]
periodSeconds: 5
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumes:
- name: data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
---
apiVersion: v1
kind: ConfigMap
metadata:
name: dex-config
data:
config.yaml: |
issuer: http://dex.default.svc.cluster.local:5556/dex
storage:
type: memory
web:
http: 0.0.0.0:5556
oauth2:
skipApprovalScreen: true
staticClients:
- id: ci-client
secret: ci-client-secret
name: CI Client
redirectURIs:
- https://uv.ci.local/login/oauth2/code/default
enablePasswordDB: true
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: dex
labels:
app: dex
spec:
replicas: 1
selector:
matchLabels:
app: dex
template:
metadata:
labels:
app: dex
spec:
containers:
- name: dex
image: ghcr.io/dexidp/dex:v2.41.1
command: ["dex", "serve", "/etc/dex/cfg/config.yaml"]
ports:
- containerPort: 5556
readinessProbe:
httpGet:
path: /dex/healthz
port: 5556
periodSeconds: 5
volumeMounts:
- name: config
mountPath: /etc/dex/cfg
volumes:
- name: config
configMap:
name: dex-config
---
apiVersion: v1
kind: Service
metadata:
name: dex
spec:
selector:
app: dex
ports:
- port: 5556
targetPort: 5556
+2
View File
@@ -0,0 +1,2 @@
# Spring Boot startup (JVM + Liquibase migrations) can take a while.
INSTALL_TIMEOUT=600
+6
View File
@@ -0,0 +1,6 @@
secrets:
databaseSecretRef: op://ci/uv-db
oidcSecretRef: op://ci/uv-oidc
ingress:
domain: uv.ci.local
+188
View File
@@ -0,0 +1,188 @@
# Fixtures for the voucher-vault install-test: throwaway postgres + redis
# (Celery broker), a dex OIDC provider and the secrets the 1Password
# operator would otherwise materialize (release name: ci).
apiVersion: v1
kind: Secret
metadata:
name: ci-voucher-vault-secret
stringData:
secretKey: voucher-vault-ci-django-secret-0123456789abcdef0123456789abcdef
redis-url: redis://:cipass@redis:6379/0
---
apiVersion: v1
kind: Secret
metadata:
name: ci-voucher-vault-db-secret
stringData:
host: postgres
port: "5432"
user: ci
password: ci
database: vouchervault
---
apiVersion: v1
kind: Secret
metadata:
name: ci-voucher-vault-oidc-secret
stringData:
oidc-jwks-endpoint: http://dex.default.svc.cluster.local:5556/dex/keys
client-id: ci-client
client-secret: ci-client-secret
authorization-endpoint: http://dex.default.svc.cluster.local:5556/dex/auth
token-endpoint: http://dex.default.svc.cluster.local:5556/dex/token
user-endpoint: http://dex.default.svc.cluster.local:5556/dex/userinfo
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
labels:
app: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: public.ecr.aws/docker/library/postgres:16-alpine
env:
- name: POSTGRES_USER
value: ci
- name: POSTGRES_PASSWORD
value: ci
- name: POSTGRES_DB
value: vouchervault
ports:
- containerPort: 5432
readinessProbe:
exec:
command: ["pg_isready", "-U", "ci"]
periodSeconds: 5
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumes:
- name: data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
labels:
app: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: public.ecr.aws/docker/library/redis:7-alpine
args: ["redis-server", "--requirepass", "cipass"]
ports:
- containerPort: 6379
readinessProbe:
tcpSocket:
port: 6379
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
---
apiVersion: v1
kind: ConfigMap
metadata:
name: dex-config
data:
config.yaml: |
issuer: http://dex.default.svc.cluster.local:5556/dex
storage:
type: memory
web:
http: 0.0.0.0:5556
oauth2:
skipApprovalScreen: true
staticClients:
- id: ci-client
secret: ci-client-secret
name: CI Client
redirectURIs:
- https://vv.ci.local/oidc/callback
enablePasswordDB: true
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: dex
labels:
app: dex
spec:
replicas: 1
selector:
matchLabels:
app: dex
template:
metadata:
labels:
app: dex
spec:
containers:
- name: dex
image: ghcr.io/dexidp/dex:v2.41.1
command: ["dex", "serve", "/etc/dex/cfg/config.yaml"]
ports:
- containerPort: 5556
readinessProbe:
httpGet:
path: /dex/healthz
port: 5556
periodSeconds: 5
volumeMounts:
- name: config
mountPath: /etc/dex/cfg
volumes:
- name: config
configMap:
name: dex-config
---
apiVersion: v1
kind: Service
metadata:
name: dex
spec:
selector:
app: dex
ports:
- port: 5556
targetPort: 5556
+8
View File
@@ -0,0 +1,8 @@
secrets:
voucherVaultSecretRef: op://ci/voucher-vault
oidcSecretRef: op://ci/voucher-vault-oidc
databaseSecretRef: op://ci/voucher-vault-db
ingress:
clusterIssuer: ci
domain: vv.ci.local
+65
View File
@@ -0,0 +1,65 @@
# Fixtures for the zipline install-test: throwaway postgres and the secrets
# the 1Password operator would otherwise materialize (release name: ci).
apiVersion: v1
kind: Secret
metadata:
name: ci-zipline-db-secret
stringData:
connection-url: postgresql://ci:ci@postgres:5432/zipline
---
apiVersion: v1
kind: Secret
metadata:
name: ci-zipline-core-secret
stringData:
server-secret: zipline-ci-core-secret-0123456789abcdef0123456789abcdef0123456789
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
labels:
app: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: public.ecr.aws/docker/library/postgres:16-alpine
env:
- name: POSTGRES_USER
value: ci
- name: POSTGRES_PASSWORD
value: ci
- name: POSTGRES_DB
value: zipline
ports:
- containerPort: 5432
readinessProbe:
exec:
command: ["pg_isready", "-U", "ci"]
periodSeconds: 5
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumes:
- name: data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
+9
View File
@@ -0,0 +1,9 @@
config:
storage: 1Gi
storageClass: local-path
domain: zipline.ci.local
# The OnePasswordItem CRs only need non-null item paths; the actual
# Kubernetes Secrets come from ci/zipline/fixtures.yaml.
secretRef:
db: op://ci/zipline-db
zipline: op://ci/zipline-core