Add self-contained API simulators lab with Compose and Helm.

Ship Proxmox, oVirt, VMware, and OpenStack behind one Postgres stack,
Makefile helpers (up/clean/helm up|down/push), and a Helm chart with
namespace, Let's Encrypt ingresses, migrate inits, and skip-if-seeded jobs.
This commit is contained in:
2026-07-18 11:52:33 +03:00
commit a3ce2d473c
24 changed files with 1960 additions and 0 deletions
@@ -0,0 +1,28 @@
1. Namespace: {{ include "lab.namespace" . }}
2. Point DNS (or /etc/hosts for local-only) at the Ingress controller:
{{- range $name, $ing := .Values.ingresses }}
{{- if $ing.enabled }}
{{ $ing.host }}
{{- end }}
{{- end }}
3. Open simulators (TLS via cert-manager / Let's Encrypt when enabled):
{{- range $name, $ing := .Values.ingresses }}
{{- if $ing.enabled }}
{{- $https := or $ing.tls $.Values.certManager.enabled }}
http{{ if $https }}s{{ end }}://{{ $ing.host }}{{ $ing.path | default "/" }}
{{- end }}
{{- end }}
{{- if .Values.certManager.enabled }}
cert-manager ClusterIssuer: {{ include "lab.clusterIssuer" . }}
{{- if .Values.certManager.createClusterIssuer }}
(created by this chart; email={{ .Values.certManager.email }})
{{- else }}
(expected to already exist in the cluster)
{{- end }}
{{- end }}
Configure namespace / hosts / TLS under `.Values.namespace`, `.Values.ingresses`, `.Values.certManager`.
@@ -0,0 +1,69 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "lab.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "lab.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{- define "lab.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "lab.labels" -}}
helm.sh/chart: {{ include "lab.chart" . }}
{{ include "lab.selectorLabels" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{- define "lab.selectorLabels" -}}
app.kubernetes.io/name: {{ include "lab.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- define "lab.postgresHost" -}}
{{- printf "%s-postgres" (include "lab.fullname" .) }}
{{- end }}
{{- define "lab.namespace" -}}
{{- .Values.namespace | default .Release.Namespace }}
{{- end }}
{{- define "lab.databaseUrl" -}}
{{- $sim := .sim -}}
{{- printf "postgresql://%s:%s@%s:5432/%s" $sim.db.user $sim.db.password (include "lab.postgresHost" .root) $sim.db.name }}
{{- end }}
{{- define "lab.commonEnv" -}}
- name: TICKET_SIGNING_KEY
valueFrom:
secretKeyRef:
name: {{ include "lab.fullname" . }}
key: ticketSigningKey
- name: LOG_LEVEL
value: {{ .Values.logLevel | quote }}
{{- end }}
{{- define "lab.clusterIssuer" -}}
{{- if .Values.certManager.useStaging }}
{{- .Values.certManager.stagingIssuerName }}
{{- else }}
{{- .Values.certManager.issuerName }}
{{- end }}
{{- end }}
@@ -0,0 +1,42 @@
{{- if and .Values.certManager.enabled .Values.certManager.createClusterIssuer }}
{{- $solverClass := .Values.certManager.solverIngressClassName | default "nginx" }}
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: {{ .Values.certManager.issuerName }}
labels:
{{- include "lab.labels" . | nindent 4 }}
spec:
acme:
email: {{ required "certManager.email is required when createClusterIssuer=true" .Values.certManager.email | quote }}
server: {{ .Values.certManager.server | quote }}
privateKeySecretRef:
name: {{ printf "%s-account-key" .Values.certManager.issuerName }}
solvers:
- http01:
ingress:
{{- if $solverClass }}
ingressClassName: {{ $solverClass }}
{{- end }}
---
{{- if .Values.certManager.createStagingIssuer }}
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: {{ .Values.certManager.stagingIssuerName }}
labels:
{{- include "lab.labels" . | nindent 4 }}
spec:
acme:
email: {{ .Values.certManager.email | quote }}
server: {{ .Values.certManager.stagingServer | quote }}
privateKeySecretRef:
name: {{ printf "%s-account-key" .Values.certManager.stagingIssuerName }}
solvers:
- http01:
ingress:
{{- if $solverClass }}
ingressClassName: {{ $solverClass }}
{{- end }}
{{- end }}
{{- end }}
@@ -0,0 +1,52 @@
{{- range $name, $ing := .Values.ingresses }}
{{- if $ing.enabled }}
{{- $svcName := printf "%s-%s" (include "lab.fullname" $) $ing.service }}
{{- $className := $ing.className | default $.Values.ingressClassName }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "lab.fullname" $ }}-{{ $name }}
namespace: {{ include "lab.namespace" $ }}
labels:
{{- include "lab.labels" $ | nindent 4 }}
app.kubernetes.io/component: ingress-{{ $name }}
annotations:
{{- if $.Values.certManager.enabled }}
cert-manager.io/cluster-issuer: {{ include "lab.clusterIssuer" $ | quote }}
{{- end }}
{{- with $ing.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if $className }}
ingressClassName: {{ $className | quote }}
{{- end }}
{{- if $ing.tls }}
tls:
{{- range $ing.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- else if $.Values.certManager.enabled }}
tls:
- hosts:
- {{ $ing.host | quote }}
secretName: {{ printf "%s-%s-tls" (include "lab.fullname" $) $name }}
{{- end }}
rules:
- host: {{ $ing.host | quote }}
http:
paths:
- path: {{ $ing.path | default "/" | quote }}
pathType: {{ $ing.pathType | default "Prefix" }}
backend:
service:
name: {{ $svcName }}
port:
number: {{ $ing.port }}
---
{{- end }}
{{- end }}
@@ -0,0 +1,8 @@
{{- if .Values.namespaceCreate }}
apiVersion: v1
kind: Namespace
metadata:
name: {{ include "lab.namespace" . }}
labels:
{{- include "lab.labels" . | nindent 4 }}
{{- end }}
@@ -0,0 +1,17 @@
{{- if .Values.postgres.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "lab.fullname" . }}-postgres-init
namespace: {{ include "lab.namespace" . }}
labels:
{{- include "lab.labels" . | nindent 4 }}
app.kubernetes.io/component: postgres
data:
01-simulators.sql: |
{{- range .Values.postgres.initDatabases }}
CREATE USER {{ .user }} WITH PASSWORD '{{ .password }}';
CREATE DATABASE {{ .database }} OWNER {{ .user }};
{{- end }}
{{- end }}
@@ -0,0 +1,110 @@
{{- if .Values.postgres.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "lab.postgresHost" . }}
namespace: {{ include "lab.namespace" . }}
labels:
{{- include "lab.labels" . | nindent 4 }}
app.kubernetes.io/component: postgres
spec:
type: ClusterIP
ports:
- name: postgres
port: 5432
targetPort: postgres
protocol: TCP
selector:
{{- include "lab.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: postgres
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "lab.postgresHost" . }}
namespace: {{ include "lab.namespace" . }}
labels:
{{- include "lab.labels" . | nindent 4 }}
app.kubernetes.io/component: postgres
spec:
serviceName: {{ include "lab.postgresHost" . }}
replicas: 1
selector:
matchLabels:
{{- include "lab.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: postgres
template:
metadata:
labels:
{{- include "lab.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: postgres
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: postgres
image: "{{ .Values.postgres.image.repository }}:{{ .Values.postgres.image.tag }}"
imagePullPolicy: {{ .Values.postgres.image.pullPolicy }}
ports:
- name: postgres
containerPort: 5432
protocol: TCP
env:
- name: POSTGRES_USER
value: {{ .Values.postgres.auth.username | quote }}
- name: POSTGRES_DB
value: {{ .Values.postgres.auth.database | quote }}
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "lab.fullname" . }}
key: postgres-password
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
- name: init
mountPath: /docker-entrypoint-initdb.d
readOnly: true
readinessProbe:
exec:
command: ["pg_isready", "-U", {{ .Values.postgres.auth.username | quote }}, "-d", {{ .Values.postgres.auth.database | quote }}]
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 12
livenessProbe:
exec:
command: ["pg_isready", "-U", {{ .Values.postgres.auth.username | quote }}, "-d", {{ .Values.postgres.auth.database | quote }}]
initialDelaySeconds: 15
periodSeconds: 10
timeoutSeconds: 3
{{- with .Values.postgres.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
volumes:
- name: init
configMap:
name: {{ include "lab.fullname" . }}-postgres-init
{{- if not .Values.postgres.persistence.enabled }}
- name: data
emptyDir: {}
{{- end }}
{{- if .Values.postgres.persistence.enabled }}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
{{- if .Values.postgres.persistence.storageClass }}
storageClassName: {{ .Values.postgres.persistence.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.postgres.persistence.size }}
{{- end }}
{{- end }}
@@ -0,0 +1,16 @@
apiVersion: v1
kind: Secret
metadata:
name: {{ include "lab.fullname" . }}
namespace: {{ include "lab.namespace" . }}
labels:
{{- include "lab.labels" . | nindent 4 }}
type: Opaque
stringData:
ticketSigningKey: {{ .Values.ticketSigningKey | quote }}
postgres-password: {{ .Values.postgres.auth.password | quote }}
{{- range $name, $sim := .Values.simulators }}
{{- if and $sim.enabled $sim.db }}
{{ $name }}-db-password: {{ $sim.db.password | quote }}
{{- end }}
{{- end }}
@@ -0,0 +1,92 @@
{{- if .Values.seedJobs.enabled }}
{{- range $name, $sim := .Values.simulators }}
{{- if and $sim.enabled $sim.seed $sim.seed.enabled }}
{{- $fullname := printf "%s-%s" (include "lab.fullname" $) $name }}
{{- $tag := $sim.image.tag | default $.Values.imageTag }}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ $fullname }}-seed
namespace: {{ include "lab.namespace" $ }}
labels:
{{- include "lab.labels" $ | nindent 4 }}
app.kubernetes.io/component: {{ $name }}-seed
annotations:
helm.sh/hook: post-install,post-upgrade
helm.sh/hook-weight: "10"
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
spec:
backoffLimit: {{ $.Values.seedJobs.backoffLimit }}
ttlSecondsAfterFinished: {{ $.Values.seedJobs.ttlSecondsAfterFinished }}
template:
metadata:
labels:
{{- include "lab.selectorLabels" $ | nindent 8 }}
app.kubernetes.io/component: {{ $name }}-seed
spec:
restartPolicy: OnFailure
{{- with $.Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
initContainers:
- name: wait-ready
image: curlimages/curl:8.12.1
imagePullPolicy: IfNotPresent
command:
- sh
- -ec
- |
url="http://{{ $fullname }}:{{ $sim.port }}{{ $sim.healthPath }}"
echo "waiting for $url"
until curl -fsS "$url" >/dev/null; do sleep 3; done
containers:
- name: seed
image: "{{ $sim.image.repository }}:{{ $tag }}"
imagePullPolicy: {{ $sim.image.pullPolicy }}
# Skip when skipIfSql returns a row (DB already seeded / has lab data).
command:
- python
- -c
- |
import asyncio, os, subprocess, sys
import asyncpg
skip_sql = os.environ.get("SEED_SKIP_IF_SQL", "").strip()
seed_cmd = {{ $sim.seed.command | toJson }}
async def already_seeded() -> bool:
if not skip_sql:
return False
conn = await asyncpg.connect(os.environ["DATABASE_URL"])
try:
return await conn.fetchval(skip_sql) is not None
except Exception as exc: # noqa: BLE001 — empty/unmigrated DB
print(f"seed: skip-check deferred ({exc})")
return False
finally:
await conn.close()
if asyncio.run(already_seeded()):
print("seed: database already has data — skipping")
raise SystemExit(0)
print("seed: applying", seed_cmd)
raise SystemExit(subprocess.call(seed_cmd))
env:
{{- include "lab.commonEnv" $ | nindent 12 }}
- name: DATABASE_URL
value: {{ include "lab.databaseUrl" (dict "root" $ "sim" $sim) | quote }}
- name: SEED_SKIP_IF_SQL
value: {{ ($sim.seed.skipIfSql | default "") | quote }}
{{- range $k, $v := $sim.env }}
- name: {{ $k }}
value: {{ $v | quote }}
{{- end }}
{{- range $k, $v := ($sim.seed.env | default dict) }}
- name: {{ $k }}
value: {{ $v | quote }}
{{- end }}
---
{{- end }}
{{- end }}
{{- end }}
@@ -0,0 +1,111 @@
{{- range $name, $sim := .Values.simulators }}
{{- if $sim.enabled }}
{{- $fullname := printf "%s-%s" (include "lab.fullname" $) $name }}
{{- $tag := $sim.image.tag | default $.Values.imageTag }}
apiVersion: v1
kind: Service
metadata:
name: {{ $fullname }}
namespace: {{ include "lab.namespace" $ }}
labels:
{{- include "lab.labels" $ | nindent 4 }}
app.kubernetes.io/component: {{ $name }}
spec:
type: ClusterIP
ports:
- name: http
port: {{ $sim.port }}
targetPort: http
protocol: TCP
selector:
{{- include "lab.selectorLabels" $ | nindent 4 }}
app.kubernetes.io/component: {{ $name }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ $fullname }}
namespace: {{ include "lab.namespace" $ }}
labels:
{{- include "lab.labels" $ | nindent 4 }}
app.kubernetes.io/component: {{ $name }}
spec:
replicas: {{ $sim.replicas | default 1 }}
selector:
matchLabels:
{{- include "lab.selectorLabels" $ | nindent 6 }}
app.kubernetes.io/component: {{ $name }}
template:
metadata:
labels:
{{- include "lab.selectorLabels" $ | nindent 8 }}
app.kubernetes.io/component: {{ $name }}
spec:
{{- with $.Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
initContainers:
- name: wait-postgres
image: "{{ $.Values.postgres.image.repository }}:{{ $.Values.postgres.image.tag }}"
imagePullPolicy: {{ $.Values.postgres.image.pullPolicy }}
command:
- sh
- -ec
- |
until pg_isready -h {{ include "lab.postgresHost" $ }} -p 5432 -U {{ $.Values.postgres.auth.username }}; do
echo "waiting for postgres..."; sleep 2;
done
{{- if and $sim.migrate $sim.migrate.enabled }}
- name: migrate
image: "{{ $sim.image.repository }}:{{ $tag }}"
imagePullPolicy: {{ $sim.image.pullPolicy }}
# Idempotent: migrate_cli applies 0 migrations when schema is already current.
command: {{- toYaml $sim.migrate.command | nindent 12 }}
env:
{{- include "lab.commonEnv" $ | nindent 12 }}
- name: DATABASE_URL
value: {{ include "lab.databaseUrl" (dict "root" $ "sim" $sim) | quote }}
{{- range $k, $v := $sim.env }}
- name: {{ $k }}
value: {{ $v | quote }}
{{- end }}
{{- end }}
containers:
- name: simulator
image: "{{ $sim.image.repository }}:{{ $tag }}"
imagePullPolicy: {{ $sim.image.pullPolicy }}
ports:
- name: http
containerPort: {{ $sim.port }}
protocol: TCP
env:
{{- include "lab.commonEnv" $ | nindent 12 }}
- name: DATABASE_URL
value: {{ include "lab.databaseUrl" (dict "root" $ "sim" $sim) | quote }}
{{- range $k, $v := $sim.env }}
- name: {{ $k }}
value: {{ $v | quote }}
{{- end }}
readinessProbe:
httpGet:
path: {{ $sim.healthPath }}
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 10
livenessProbe:
httpGet:
path: {{ $sim.healthPath }}
port: http
initialDelaySeconds: 30
periodSeconds: 20
timeoutSeconds: 3
{{- with $sim.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
---
{{- end }}
{{- end }}