fix(mariadb): re-guard replication so single-replica passes the operator webhook (#3180)

## What this PR does

Fixes a regression where **single-replica MariaDB can no longer be
created**.

[PR #2279](https://github.com/cozystack/cozystack/pull/2279) made
`replication.enabled: true` unconditional in the MariaDB CR so the
operator would always create `<release>-primary` / `<release>-secondary`
Services (expected by the dashboard RBAC and the ApplicationDefinition).
But the vendored mariadb-operator's validating webhook
(`vmariadb-v1alpha1.kb.io`) **rejects `replication.enabled: true` when
`spec.replicas == 1`** — replication needs a primary + at least one
replica. As a result, single-replica installs never became Ready (the
HelmRelease failed at admission).

The webhook rule lives in the operator's Go code (not in the vendored
CRD — there is no CEL rule tying replication to replica count), so the
failing path is reproduced by an e2e test rather than a schema
assertion.

### Fix — re-guard replication, teach consumers about the bare service

The operator provisions a bare `<release>` Service in **both**
topologies; `-primary`/`-secondary` exist **only** when replication is
on (`replicas>1`). Instead of forcing replication for everyone, this PR
lets replication track the replica count and makes the service-name
consumers handle the single-replica bare service:

- **`mariadb.yaml`** — gate `replication` on `replicas>1`; restore
single-replica external access via `service.type: LoadBalancer`, and
keep `primaryService: LoadBalancer` for `replicas>1` only. (Effectively
reverts #2279's two hunks.)
- **`backup-cronjob.yaml`** — single-replica dumps target the bare
service (no `-secondary` exists without replication); `replicas>1` still
offloads to `-secondary`.
- **`dashboard-resourcemap.yaml`** — always grant RBAC on the bare
service, plus `-primary`/`-secondary` when `replicas>1`, matching what
the ApplicationDefinition lists.
- **`mariadb-rd`** — list the bare service in `services.include` so
single-replica installs are visible in the dashboard.
- **`hack/e2e-apps/mariadb.bats`** — new `Create single-replica MariaDB`
test that reproduces the webhook rejection and guards against
regression.

Verified: `helm template` at `replicas=1`/`2` renders the intended
CR/RBAC/backup host; `helm unittest` passes; `update-crd.sh` keeps the
RD stable; `select-e2e.sh` maps the change to the mariadb suite.

### Release note

```release-note
fix(mariadb): single-replica instances can be created again — replication is now enabled only for replicas>1 (matching the operator webhook), while the dashboard, RBAC, and backups correctly target the bare service for single-replica setups
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added an end-to-end test covering MariaDB deployments with a single
replica, including validation of the main service, endpoints,
StatefulSet replica count, and metrics components.

* **Bug Fixes**
* Updated MariaDB chart behavior to correctly handle single-replica vs
multi-replica configurations for external access, replication settings,
and backup host selection.
* Refined dashboard RBAC and secret inclusion so access applies to the
main MariaDB service in addition to replica-specific services.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
scooby87 authored and GitHub committed 2026-07-02 22:54:14 +03:00
commit 71ca2b1bae
5 files changed
+75 -2

No files matched your search

+59
View File
@@ -49,3 +49,62 @@ EOF
kubectl -n tenant-test wait deployment.apps/mariadb-$name-metrics --timeout=90s --for=jsonpath='{.status.replicas}'=1
kubectl -n tenant-test delete mariadbs.apps.cozystack.io $name
}
@test "Create single-replica MariaDB" {
name='single'
kubectl -n tenant-test delete mariadbs.apps.cozystack.io $name --ignore-not-found --timeout=2m
kubectl apply -f- <<EOF
apiVersion: apps.cozystack.io/v1alpha1
kind: MariaDB
metadata:
name: $name
namespace: tenant-test
spec:
external: false
size: 10Gi
replicas: 1
storageClass: ""
users:
testuser:
maxUserConnections: 1000
password: xai7Wepo
databases:
testdb:
roles:
admin:
- testuser
backup:
enabled: false
s3Region: us-east-1
s3Bucket: s3.example.org/mariadb-backups
schedule: "0 2 * * *"
cleanupStrategy: "--keep-last=3 --keep-daily=3 --keep-within-weekly=1m"
s3AccessKey: oobaiRus9pah8PhohL1ThaeTa4UVa7gu
s3SecretKey: ju3eum4dekeich9ahM1te8waeGai0oog
resticPassword: ChaXoveekoh6eigh4siesheeda2quai0
resources: {}
resourcesPreset: "nano"
EOF
# Wait for the operator to materialise the HelmRelease before kubectl wait
# kicks in (kubectl wait errors immediately if the object does not exist yet).
timeout 60 sh -ec "until kubectl -n tenant-test get hr mariadb-$name >/dev/null 2>&1; do sleep 2; done"
# A single-replica MariaDB must be accepted by the operator's validating
# webhook: replication is guarded on replicas>1, so replicas=1 renders a CR
# the webhook admits. Dump the HR + rendered CR on failure so a webhook
# rejection (the regression this guards against) is legible in the log.
kubectl -n tenant-test wait hr mariadb-$name --timeout=5m --for=condition=ready \
|| { echo "HR mariadb-$name not ready — dumping state:"; \
kubectl -n tenant-test describe hr mariadb-$name; \
kubectl -n tenant-test get mariadbs.k8s.mariadb.com mariadb-$name -o yaml; false; }
# With replicas=1 the operator provisions the bare <name> service (no
# -primary/-secondary): assert it exists and has an endpoint.
timeout 80 sh -ec "until kubectl -n tenant-test get svc mariadb-$name -o jsonpath='{.spec.ports[0].port}' | grep -q '3306'; do sleep 10; done"
timeout 80 sh -ec "until kubectl -n tenant-test get endpoints mariadb-$name -o jsonpath='{.subsets[*].addresses[*].ip}' | grep -q '[0-9]'; do sleep 10; done"
timeout 60 sh -ec "until kubectl -n tenant-test get statefulset.apps/mariadb-$name >/dev/null 2>&1; do sleep 2; done"
kubectl -n tenant-test wait statefulset.apps/mariadb-$name --timeout=110s --for=jsonpath='{.status.replicas}'=1
timeout 80 sh -ec "until kubectl -n tenant-test get svc mariadb-$name-metrics -o jsonpath='{.spec.ports[0].port}' | grep -q '9104'; do sleep 10; done"
timeout 40 sh -ec "until kubectl -n tenant-test get endpoints mariadb-$name-metrics -o jsonpath='{.subsets[*].addresses[*].ip}' | grep -q '[0-9]'; do sleep 10; done"
timeout 60 sh -ec "until kubectl -n tenant-test get deployment.apps/mariadb-$name-metrics >/dev/null 2>&1; do sleep 2; done"
kubectl -n tenant-test wait deployment.apps/mariadb-$name-metrics --timeout=90s --for=jsonpath='{.status.replicas}'=1
kubectl -n tenant-test delete mariadbs.apps.cozystack.io $name --ignore-not-found
}
@@ -41,7 +41,9 @@ spec:
name: {{ .Release.Name }}
key: root-password
- name: MYSQL_HOST
value: "{{ .Release.Name }}-{{ if eq (int .Values.replicas) 1 }}primary{{ else }}secondary{{ end }}"
# replicas>1 enables replication (offload dumps to a secondary);
# replicas=1 has no replication and only the bare service exists.
value: "{{ .Release.Name }}{{- if gt (int .Values.replicas) 1 }}-secondary{{- end }}"
- name: AWS_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
@@ -8,8 +8,14 @@ rules:
resources:
- services
resourceNames:
# The bare <name> service exists in both topologies; -primary/-secondary
# only when replication is on (replicas>1). Grant exactly what exists so the
# RBAC matches the services the ApplicationDefinition (mariadb-rd) lists.
- {{ .Release.Name }}
{{- if gt (int .Values.replicas) 1 }}
- {{ .Release.Name }}-primary
- {{ .Release.Name }}-secondary
{{- end }}
verbs: ["get", "list", "watch"]
- apiGroups:
- ""
+6 -1
View File
@@ -29,11 +29,13 @@ spec:
- {{ .Release.Name }}
topologyKey: "kubernetes.io/hostname"
{{- if gt (int .Values.replicas) 1 }}
replication:
enabled: true
#primary:
# podIndex: 0
# automaticFailover: true
{{- end }}
podMetadata:
labels:
@@ -71,6 +73,9 @@ spec:
metadata:
labels:
app.kubernetes.io/instance: {{ $.Release.Name }}
{{- if and .Values.external (eq (int .Values.replicas) 1) }}
type: LoadBalancer
{{- end }}
storage:
size: {{ .Values.size }}
resizeInUseVolumes: true
@@ -79,7 +84,7 @@ spec:
storageClassName: {{ . }}
{{- end }}
{{- if .Values.external }}
{{- if and .Values.external (gt (int .Values.replicas) 1) }}
primaryService:
type: LoadBalancer
{{- end }}
@@ -35,5 +35,6 @@ spec:
exclude: []
include:
- resourceNames:
- mariadb-{{ .name }}
- mariadb-{{ .name }}-primary
- mariadb-{{ .name }}-secondary