Skip to content

feat(mariadb): add TLS support via cert-manager - #2680

Merged
Aleksei Sviridkin (lexfrei) merged 84 commits into
mainfrom
feat/tls-mariadb
Sep 4, 2026
Merged

Aleksei Sviridkin (lexfrei) merged 84 commits into
mainfrom
feat/tls-mariadb

Conversation

@Arsolitt

@Arsolitt Arsolitt (Arsolitt) commented May 19, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

Gives MariaDB a chart-managed certificate chain, makes TLS enforcement explicit, and hands the tenant a key-free trust anchor instead of a private key.

  • tls.issuer selects who issues the server certificate: operator (the CA mariadb-operator manages itself, the default and today's behaviour) or cert-manager (the instance gets its own CA, and this is what covers the external hostname). The field is named for the issuer rather than for on/off because TLS is served under both — unlike the similarly spelled field in some other charts, which really is a switch.
  • tls.required refuses plaintext connections. It is opt-in and defaults to false: enforcing TLS before the tenant can obtain the CA would be backwards, and a platform upgrade must not sever connections that work today.
  • Neither issuer is inferred from external. Existing instances keep the CA they have and change nothing on upgrade.
  • The tenant no longer receives <release>-ca-tls or <release>-tls — the CA private key and the server private key. It receives the operator's ca.crt-only bundle, which works today, and the ResourceDefinition additionally declares that bundle as the source for the CA-extraction controller.

Fixes along the way

  • The TLS backup path could never have worked: it passed --ssl-mode=VERIFY_IDENTITY, a MySQL-8 flag the MariaDB client rejects during argument parsing. Replaced with --ssl-ca + --ssl-verify-server-cert, and the test now asserts the dialect rather than the presence of a substring.
  • The leaf certificate carried clientAuth. Because REQUIRE X509 accepts any certificate signed by a trusted CA, the server's own key was usable as a client credential. Removed.
  • The CA and server private keys survived deletion of the instance: cert-manager runs with enableCertificateOwnerRef: false, so removing the Certificate left the Secrets behind. Both are now cleaned up by the existing post-delete hook.
  • The Secrets are labelled for the operator's watch predicate, without which a renewed leaf would not reach the pods until a full resync.

Why the include carries an exclude list

The trust-anchor selector matches by label with no name constraint, so the enumerable dangerous set is excluded explicitly — both issuers' private keys, the internal credentials, the backup keys, and the metrics configuration, which the operator writes unconditionally with credentials in plain text for a user holding SELECT on everything.

The backstop is bounded and the comment says so: matching is by exact name with no globs, so a Secret introduced by a future operator version, or a transient object created mid-renewal, stays in scope of the label-only include. The primary defence remains that the controller stamps only its own projection.

Known limitations

  1. Merge is blocked on feat(controller): engine-agnostic CA extraction for the tenant trust anchor #3299 — and the blocking is mechanical, not just procedural. The CRD on main does not carry spec.caCert, and under Flux's Server-Side Apply the API server rejects a field the schema does not declare (field not declared in schema); under the older client-side apply the same field was silently dropped, so installing this branch without feat(controller): engine-agnostic CA extraction for the tenant trust anchor #3299 fails outright. An earlier revision of this description claimed the field would be silently pruned; that was wrong. The tenant still gets a usable trust anchor once merged in order, through the operator's bundle.
  2. The deprecated backup CronJob cannot start at all, for reasons that predate this branch: its secretKeyRef names a Secret and key the chart never renders. That is filed separately as bug(apps/mariadb): deprecated backup CronJob cannot start — secretKeyRef names a Secret and key that do not exist #3347 and deliberately not fixed here, since repairing it would start scheduled dumps on production instances from a TLS change. The tests that pin the backup TLS flags therefore pin a path that does not currently execute — the suite header says so.
  3. The operator's clusterName is hardcoded, so on a cluster with a non-default domain the SANs and the name the operator dials diverge. Pre-existing, raised separately.
  4. No e2e coverage for the managed path. Unit coverage pins the rendered chain, the SAN set, the exclusion set and the guards; the end-to-end handshake is not asserted in CI.

On review coverage

Two independent review passes were run repeatedly against this branch — seven rounds, converging from a security finding, through comment accuracy, to guard strength, and finally to nothing new. A third, model-based pass reviewed the branch through 6cb1bd236 and its findings were fixed; it could not be run against the final revision because of an external account quota lasting until 25 July. The delta since that revision is substantial and includes runtime code, not only tests and docs. Recording this so the coverage is not overstated: if the quota returns before this is ready to merge, that pass is worth running again.

Downstream repositories

packages/apps/mariadb/values.schema.json gains the tls object, so cozystack/terraform-provider-cozystack needs the field modelled for cozystack_mariadb; tracked in cozystack/terraform-provider-cozystack#40.

Release note

feat(mariadb): chart-managed TLS via cert-manager, opt-in enforcement, and the CA published to tenants as a key-free trust anchor

@coderabbitai

coderabbitai Bot commented May 19, 2026 •

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds tri-state TLS support across CRD types and deepcopy, Helm values/schema/docs, a Helm tri-state helper, cert-manager CA/leaf templates, MariaDB spec and RBAC wiring, backup TLS wiring, extensive helm-unittest coverage, and ApplicationDefinition schema propagation.

Changes

MariaDB TLS Feature

Layer / File(s) Summary
CRD Contract & Deep-Copy Methods
api/apps/v1alpha1/mariadb/types.go, api/apps/v1alpha1/mariadb/zz_generated.deepcopy.go
Extends ConfigSpec with Tls field and adds exported TLS struct (Enabled *bool, Required bool) plus generated deepcopy methods handling the Enabled pointer.
Helm Values, Schema & Docs
packages/apps/mariadb/values.yaml, packages/apps/mariadb/values.schema.json, packages/apps/mariadb/README.md
Adds top-level tls values (enabled tri-state, required default true), updates values schema and README TLS parameters.
Tri-State Helper & Build Target
packages/apps/mariadb/templates/_tls.tpl, packages/apps/mariadb/Makefile
Implements mariadb.tls.enabled helper to normalize tri-state tls.enabled with .Values.external fallback; adds test Makefile target (helm unittest .).
Cert-Manager Certificate Provisioning
packages/apps/mariadb/templates/certmanager.yaml
Conditionally renders self-signed Issuer, CA Certificate, CA-backed Issuer, and leaf Certificate with SAN lists, ECDSA keys, and rotation policies when TLS is enabled.
MariaDB Spec & RBAC Configuration
packages/apps/mariadb/templates/mariadb.yaml, packages/apps/mariadb/templates/dashboard-resourcemap.yaml
Gates spec.tls rendering on helper output, sets spec.tls.enabled/required and secret refs, and conditionally adds TLS secret names to dashboard Role resourceNames.
Backup CronJob & Script TLS Wiring
packages/apps/mariadb/templates/backup-cronjob.yaml, packages/apps/mariadb/templates/backup-script.yaml
Backup CronJob mounts CA secret and backup script sets mysqldump SSL_OPTS (--ssl-mode=VERIFY_IDENTITY --ssl-ca=...) when TLS enabled; omitted otherwise.
Comprehensive TLS Test Suite
packages/apps/mariadb/tests/tls_test.yaml
Adds extensive helm-unittest coverage for tri-state logic, null handling, cert-manager outputs, SAN composition, EKU/usages, rotation policies, RBAC secret wiring, and backup TLS behavior.
Application Definition Schema Propagation
packages/system/mariadb-rd/cozyrds/mariadb.yaml
Adds spec.tls to ApplicationDefinition openAPISchema and updates keysOrder entries for spec.tls and spec.tls.required.

Sequence Diagram

sequenceDiagram
  participant Values
  participant Helper
  participant CertMgr
  participant MariaDB
  participant RBAC

  Values->>Helper: provide external and tls.enabled
  Helper->>CertMgr: emit tlsEnabled
  CertMgr->>CertMgr: create Issuer, CA, CA-Issuer, leaf Certificate
  CertMgr->>MariaDB: create TLS secrets (CA, leaf)
  MariaDB->>MariaDB: render spec.tls and secret refs
  Helper->>RBAC: tlsEnabled
  RBAC->>RBAC: include or exclude TLS secrets and mounts
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested labels

size/XXL, area/platform

Suggested reviewers

  • kvaps
  • sircthulhu
  • IvanHunters
  • lllamnyp
  • myasnikovdaniil

🐰 I hopped through charts and schemas bright,
Tri-state flags now guide TLS right,
CA roots steady, leafs renewed on cue,
Backups and RBAC now know what to do,
Cozy DB snug beneath TLS night.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding cert-manager-based TLS support for MariaDB.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/tls-mariadb

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added size/XL This PR changes 500-999 lines, ignoring generated files area/database Issues or PRs related to managed databases (postgres, mariadb, redis, etcd, kafka, clickhouse) kind/feature Categorizes issue or PR as related to a new feature labels May 19, 2026
@Arsolitt
Arsolitt (Arsolitt) marked this pull request as ready for review May 19, 2026 13:03
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces robust TLS support for the MariaDB managed service, leveraging cert-manager to handle certificate lifecycle management. By providing a configurable tri-state TLS enablement policy and a dedicated requirement toggle, the changes allow for secure-by-default deployments while maintaining backward compatibility for existing services and migration paths. The implementation includes automated certificate chain generation, SAN policy management, and full integration with the MariaDB operator, all validated through a new suite of unit tests.

Highlights

  • TLS Support: Added TLS support for MariaDB managed services using cert-manager, including automated certificate chain generation and wiring into the MariaDB CR.
  • Tri-state Configuration: Introduced a tri-state tls.enabled field that defaults to the external access setting, providing flexible control over TLS enablement.
  • Security Enforcement: Added a tls.required toggle to control MariaDB's require_secure_transport setting, allowing for secure-by-default configurations with migration flexibility.
  • Testing: Implemented comprehensive helm unittests to verify TLS resource rendering and tri-state logic across various configuration scenarios.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Ignored Files
  • Ignored by pattern: **/zz_generated.*.go (1)
    • api/apps/v1alpha1/mariadb/zz_generated.deepcopy.go
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment Gemini (@gemini-code-assist) Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements TLS support for MariaDB with tri-state logic, automatically enabling TLS for external access unless configured otherwise. It adds cert-manager templates, updates RBAC for TLS secrets, and includes unit tests. Feedback highlights a high-severity issue where incorrect SANs in the certificate template would cause internal TLS verification failures. Other suggestions include adding standard labels to cert-manager resources and explicitly defining default values in values.yaml to improve schema visibility.

Comment thread packages/apps/mariadb/templates/certmanager.yaml
Comment thread packages/apps/mariadb/templates/certmanager.yaml
Comment thread packages/apps/mariadb/values.yaml Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/apps/mariadb/README.md`:
- Line 105: The README shows `version` under TLS because the `values.yaml` lacks
a section separator before the Version enum; open the MariaDB chart's
values.yaml and insert a new section marker (e.g., `@section` or the chart's
existing section delimiter style) immediately before the `version` parameter so
that `version` is moved out of the TLS section into its own section (or place it
into the "Application-specific parameters" section); this change to values.yaml
will let cozyvalues-gen regenerate the README with `version` shown in the
correct section.

In `@packages/system/mariadb-rd/cozyrds/mariadb.yaml`:
- Line 28: The keysOrder entry currently includes ["spec","tls"] but omits the
TLS child keys, causing unstable field ordering; update the keysOrder array (the
keysOrder symbol) to include ["spec","tls","enabled"] and
["spec","tls","required"] immediately after ["spec","tls"] so TLS child fields
render in the intended, stable order in the dashboard.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7bca795b-ff4c-4a15-b0e7-4f66d5ad1e9a

📥 Commits

Reviewing files that changed from the base of the PR and between c5e9287 and 5c6adbf.

📒 Files selected for processing (12)
  • api/apps/v1alpha1/mariadb/types.go
  • api/apps/v1alpha1/mariadb/zz_generated.deepcopy.go
  • packages/apps/mariadb/Makefile
  • packages/apps/mariadb/README.md
  • packages/apps/mariadb/templates/_tls.tpl
  • packages/apps/mariadb/templates/certmanager.yaml
  • packages/apps/mariadb/templates/dashboard-resourcemap.yaml
  • packages/apps/mariadb/templates/mariadb.yaml
  • packages/apps/mariadb/tests/tls_test.yaml
  • packages/apps/mariadb/values.schema.json
  • packages/apps/mariadb/values.yaml
  • packages/system/mariadb-rd/cozyrds/mariadb.yaml

Comment thread packages/apps/mariadb/README.md Outdated
Comment thread packages/system/mariadb-rd/cozyrds/mariadb.yaml Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
packages/system/mariadb-rd/cozyrds/mariadb.yaml (1)

28-28: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add nested TLS entries to keysOrder for stable dashboard field ordering.

The keysOrder array includes ["spec","tls"] but omits ["spec","tls","enabled"] and ["spec","tls","required"]. This is inconsistent with the existing pattern used for backup, which includes both the parent key and all nested keys (e.g., ["spec","backup"], ["spec","backup","enabled"], etc.). Without the nested entries, TLS child fields may not render in a stable, predictable order in the dashboard.

Suggested fix
-    keysOrder: [["apiVersion"], ["appVersion"], ["kind"], ["metadata"], ["metadata", "name"], ["spec", "replicas"], ["spec", "resources"], ["spec", "resourcesPreset"], ["spec", "size"], ["spec", "storageClass"], ["spec", "external"], ["spec", "tls"], ["spec", "version"], ["spec", "users"], ["spec", "databases"], ["spec", "backup"], ["spec", "backup", "enabled"], ["spec", "backup", "s3Region"], ["spec", "backup", "s3Bucket"], ["spec", "backup", "schedule"], ["spec", "backup", "cleanupStrategy"], ["spec", "backup", "s3AccessKey"], ["spec", "backup", "s3SecretKey"], ["spec", "backup", "resticPassword"]]
+    keysOrder: [["apiVersion"], ["appVersion"], ["kind"], ["metadata"], ["metadata", "name"], ["spec", "replicas"], ["spec", "resources"], ["spec", "resourcesPreset"], ["spec", "size"], ["spec", "storageClass"], ["spec", "external"], ["spec", "tls"], ["spec", "tls", "enabled"], ["spec", "tls", "required"], ["spec", "version"], ["spec", "users"], ["spec", "databases"], ["spec", "backup"], ["spec", "backup", "enabled"], ["spec", "backup", "s3Region"], ["spec", "backup", "s3Bucket"], ["spec", "backup", "schedule"], ["spec", "backup", "cleanupStrategy"], ["spec", "backup", "s3AccessKey"], ["spec", "backup", "s3SecretKey"], ["spec", "backup", "resticPassword"]]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/system/mariadb-rd/cozyrds/mariadb.yaml` at line 28, The keysOrder
entry lists ["spec","tls"] but is missing explicit nested entries for TLS;
update the keysOrder array to include ["spec","tls","enabled"] and
["spec","tls","required"] (mirroring the existing pattern used for
["spec","backup"] and its nested keys) so TLS child fields render in a stable
order in the dashboard; modify the keysOrder definition in mariadb.yaml (the
keysOrder array) to add those two nested entries adjacent to ["spec","tls"] to
preserve predictable field ordering.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@packages/system/mariadb-rd/cozyrds/mariadb.yaml`:
- Line 28: The keysOrder entry lists ["spec","tls"] but is missing explicit
nested entries for TLS; update the keysOrder array to include
["spec","tls","enabled"] and ["spec","tls","required"] (mirroring the existing
pattern used for ["spec","backup"] and its nested keys) so TLS child fields
render in a stable order in the dashboard; modify the keysOrder definition in
mariadb.yaml (the keysOrder array) to add those two nested entries adjacent to
["spec","tls"] to preserve predictable field ordering.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7b675810-7dfc-4f1d-9229-461aa5392df4

📥 Commits

Reviewing files that changed from the base of the PR and between 5c6adbf and b23ead9.

📒 Files selected for processing (12)
  • api/apps/v1alpha1/mariadb/types.go
  • api/apps/v1alpha1/mariadb/zz_generated.deepcopy.go
  • packages/apps/mariadb/Makefile
  • packages/apps/mariadb/README.md
  • packages/apps/mariadb/templates/_tls.tpl
  • packages/apps/mariadb/templates/certmanager.yaml
  • packages/apps/mariadb/templates/dashboard-resourcemap.yaml
  • packages/apps/mariadb/templates/mariadb.yaml
  • packages/apps/mariadb/tests/tls_test.yaml
  • packages/apps/mariadb/values.schema.json
  • packages/apps/mariadb/values.yaml
  • packages/system/mariadb-rd/cozyrds/mariadb.yaml
✅ Files skipped from review due to trivial changes (2)
  • packages/apps/mariadb/README.md
  • api/apps/v1alpha1/mariadb/zz_generated.deepcopy.go

@Arsolitt Arsolitt (Arsolitt) self-assigned this May 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOT LGTM — three security/correctness blockers: the chart's own legacy backup CronJob will start failing the moment TLS becomes default-on, the server certificate is missing digital signature (modern TLS clients reject it), and the dashboard use role is granted get/list/watch on the CA private key. The cert-manager chain itself is well-shaped (RSA-4096, sane lifetimes, rotation policy) and the SAN list matches the upstream operator's InternalServiceName (<name>-internal); below are the three items that need to land before merge.

Business context: First-class TLS for the managed MariaDB service via a self-contained cert-manager chain in the tenant namespace, with a tri-state tls.enabled that auto-on for externally-published instances.

Blockers

B1: tls.required: true default breaks chart's own backup CronJob

Files: packages/apps/mariadb/templates/mariadb.yaml:88-92 + packages/apps/mariadb/templates/backup-script.yaml:14,29

Issue: After this PR ships, any tenant with both external: true (or any explicit tls.enabled: true) and backup.enabled: true will lose backups silently on upgrade. The MariaDB CR's tls.required: true renders require_secure_transport=ON on mysqld, which rejects plaintext TCP connections. The chart-internal backup CronJob still runs mysql -u "$MYSQL_USER" -h "$MYSQL_HOST" and mysqldump ... -h "$MYSQL_HOST" with no --ssl-* flags or --ssl-mode=REQUIRED.

Evidence: backup-script.yaml:14,29 are unchanged plain-mysql clients; MYSQL_HOST resolves to <name>-primary or <name>-secondary (line 44 of backup-cronjob.yaml), which are TCP services. require_secure_transport rejects plain TCP regardless of credentials. Manifests helm template-rendered against external: true, backup.enabled: true produce a CronJob that will exit non-zero on every fire.

Impact: Silent backup regression on the subset (external-published + backup-enabled tenants) that is precisely the riskiest cohort. The PR description notes backup is deprecated but the chart still ships it; the new default cannot break it without an explicit upgrade note plus a fallback path.

Fix — pick one of:

  • Mount <name>-ca-tls into the CronJob and pass --ssl-ca=/path/ca.crt --ssl-mode=VERIFY_CA (or VERIFY_IDENTITY) to both mysql and mysqldump.
  • Gate the tls.required: true default on backup.enabled being false, with an explicit warning in the README that operators using the legacy backup must move to BackupClass before enabling TLS.
  • Treat the legacy backup as fully retired and remove it in the same PR rather than half-supporting it.

B2: Server certificate missing digital signature key usage

File: packages/apps/mariadb/templates/certmanager.yaml:62-65

Issue: The leaf Certificate explicitly declares usages: [server auth, client auth, key encipherment]. Because the field is set explicitly, cert-manager will NOT apply its default key usages on top. digital signature is missing.

Evidence: For TLS 1.3 (and TLS 1.2 with any ECDHE/DHE cipher suite — which is the default since OpenSSL 1.1.0 and Go's crypto/tls), the server signs the ephemeral key-exchange parameters with the cert private key. RFC 5246 §7.4.1.4.1 and RFC 8446 §4.4 require the cert to assert digitalSignature for that operation when keyUsage is present and critical: true. cert-manager's standard server profile is digital signature, key encipherment, server auth; the omission here will be rejected by strict clients (Go's tls.Config{InsecureSkipVerify:false}, recent OpenJDK with strict KU checks, BoringSSL). key encipherment only covers the legacy RSA key-transport suites, which TLS 1.3 doesn't have at all.

Impact: TLS handshake failures from modern, RFC-conformant clients once they connect to a TLS-enabled MariaDB. The bug is silent on the chart side — cert-manager happily issues a cert with the wrong usages and mysqld serves it.

Fix: Add digital signature to the usages list in certmanager.yaml:62-65.

B3: Dashboard RBAC leaks the CA private key and the server private key to every tenant user

File: packages/apps/mariadb/templates/dashboard-resourcemap.yaml:21-23

Issue: When TLS is enabled, the {{ .Release.Name }}-dashboard-resources Role grants get/list/watch on Secrets <name>-ca-tls and <name>-tls. The RoleBinding subjects (line 39, via cozy-lib.rbac.subjectsForTenantAndAccessLevel "use") cover every group with use access or higher in the tenant — i.e. every tenant member. cert-manager Secrets of kubernetes.io/tls type carry tls.key alongside tls.crt and ca.crt. The Role lets any tenant member run kubectl -n <ns> get secret <name>-ca-tls -o yaml and recover both the CA private key and the server private key.

Evidence: cert-manager produces standard tls-type Secrets containing both tls.key and tls.crt; for the CA Certificate (certmanager.yaml:16-37), tls.key is the CA's private key. Kubernetes RBAC at the Secret level cannot scope to individual keys — any verb on the secret exposes every field. The cozy-lib.rbac.accessLevelMap defines use as level 1 (just above view), so the binding extends to tenant-<X>-use, tenant-<X>-admin, tenant-<X>-super-admin groups.

Impact: A leaked CA private key allows minting an arbitrarily-named server cert that any client trusting this CA (notably MariaDB clients pinned via --ssl-ca) will accept — instant MITM of every MariaDB connection in the tenant. A leaked server private key allows passive decryption of TLS sessions captured anywhere on the network path (for non-PFS suites) and direct impersonation of the MariaDB server.

Fix: Drop both Secret names from the dashboard Role. If the dashboard genuinely needs to display the trust bundle (ca.crt), publish that single field to a separate ConfigMap (e.g. via a small helper template that reads the Secret in a post-install Job and writes the CA cert into a ConfigMap, or via additionalOutputFormats on the Certificate object) and grant get on that ConfigMap instead. Under no circumstance should tls.key reach a use-level subject.

Non-blocking follow-ups

  1. dashboard-resourcemap.yaml exposes the TLS secrets in RBAC, but packages/system/mariadb-rd/cozyrds/mariadb.yaml doesn't list them under secrets.include — the two are out of sync. Once B3 is resolved by replacing the secret with a ConfigMap, cozyrds/mariadb.yaml is the place to add it under configMaps.include (or remove the dashboard reference entirely).

  2. certmanager.yaml:35-37: CA usages [signing, cert sign] cover the issuance path. Adding crl sign is conventional (even if no CRL is published today) and costs nothing.

  3. The SAN list (certmanager.yaml:69-78) covers the three FQDN forms per service plus the wildcard pod-FQDN for -internal. Upstream operator docs list a few extra short forms (<name>.<ns> without .svc, *.<name>-internal without <ns>.svc). Not needed in practice unless clients resolve via ndots-shortened forms, but worth adding for full alignment with upstream's documented set.

If the same certmanager.yaml pattern (B2) and dashboard RBAC pattern (B3) repeat across the sister TLS PRs (#2681 Kafka, #2682 OpenSearch, #2683 RabbitMQ, #2684 NATS, #2685 Qdrant), the fixes should land in lockstep — those PRs will likely surface the same two blockers once reviewed.

@Arsolitt

Copy link
Copy Markdown
Contributor Author

Addressed your review. Summary:

Blockers:

  • B1 (backup CronJob breaks under tls.required: true) — fixed in 88be3fc1e. The CronJob now projects <release>-tls's ca.crt into /etc/mysql-ssl/, and mysql / mysqldump conditionally append --ssl-ca=/etc/mysql-ssl/ca.crt --ssl-mode=VERIFY_IDENTITY when TLS is enabled. The cert SAN list already covers <release>-primary and <release>-secondary (the Service names backup uses via MYSQL_HOST), so VERIFY_IDENTITY succeeds. Backup remains plaintext when TLS is off — no regression for existing tenants.
  • B2 (leaf cert missing digital signature) — fixed in a7417e81b.
  • B3 (dashboard RBAC exposes both <release>-tls and <release>-ca-tls) — accepted as a series-wide tradeoff for this iteration. Tenants retrieve ca.crt via the same RBAC; the leaf-key exposure is the cost of avoiding a separate CA-only ConfigMap distribution mechanism. To revisit when a cluster-wide ca-bundle pattern lands.

Non-blocking follow-ups:

  • FU1 (cozyrds out of sync with dashboard RBAC) — tied to B3 decision, no change in this PR.
  • FU2 (crl sign on CA usages) — added in 4c1792e2d.
  • FU3 (short-form SAN variants <name>.<ns> and *.<name>-internal) — added in 87f3e0877.

Series-wide cleanups applied here too:

  • Schema cleanup (8798c26d0): dropped ["boolean","null"] from tls.enabled — that array form silently broke unmarshal into apiextv1.JSONSchemaProps (single-string Type) and disabled server-side defaulting. Template now uses kindIs "invalid" for tri-state null-detection.
  • Private keys unified to ECDSA P-256 for both CA and leaf in a8d14125e. Note for upgrade path: cert-manager will reissue the chain on first reconcile, so existing tenant trust anchors must be refreshed.

Ready for re-review.

@lexfrei Aleksei Sviridkin (lexfrei) left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOT LGTM — re-review on head 7c51f41a. Two blockers remain. One of them reopens a point I previously marked resolved: the TLS backup fix does not work against the client the backup image actually ships.

Business context: first-class TLS for the managed MariaDB service via a self-contained cert-manager chain (SelfSigned → CA → leaf) in the tenant namespace, with a tri-state tls.enabled that auto-enables for externally-published instances.

Blockers

B1 (reopened): the TLS backup path uses a client flag the backup image does not support
File: packages/apps/mariadb/templates/backup-script.yaml:15
Issue: with TLS on, the script sets SSL_OPTS="--ssl-ca=/etc/mysql-ssl/ca.crt --ssl-mode=VERIFY_IDENTITY" and passes it to mysql/mysqldump. --ssl-mode is an Oracle MySQL-8 client flag; the backup image (images/mariadb-backup/Dockerfile: alpine:3.20 + apk add mariadb-client) ships MariaDB's own client, which has no such option.
Evidence: running the image base — mysqldump --ssl-mode=VERIFY_IDENTITY on mariadb-client 10.11 → mysqldump: unknown variable 'ssl-mode=VERIFY_IDENTITY'. The client fatal-errors on the unknown variable before connecting, so the DB_LIST=$(mysql ... $SSL_OPTS ...) discovery and every subsequent mysqldump fail. The helm-unittest suite (tests/tls_test.yaml:859-872) only asserts the rendered script contains the string --ssl-mode=VERIFY_IDENTITY, so it is green while the runtime is broken. My earlier acceptance of this fix was wrong — the flag as written cannot work against this client.
Impact: every TLS-enabled backup job fails before dumping a single database — silent backup loss for exactly the external-published + backup-enabled cohort (where tls.required: true auto-applies).
Fix: use the MariaDB-client equivalent: SSL_OPTS="--ssl-ca=/etc/mysql-ssl/ca.crt --ssl-verify-server-cert" (--ssl-ca implies --ssl; --ssl-verify-server-cert adds the hostname check that VERIFY_IDENTITY provides). Update the test to assert the corrected flag.

B2: dashboard RBAC grants the CA and server private keys to every tenant use member
File: packages/apps/mariadb/templates/dashboard-resourcemap.yaml:22-23
Issue: with TLS on, the -dashboard-resources Role grants get/list/watch on Secrets <release>-ca-tls and <release>-tls, bound (line 39) to cozy-lib.rbac.subjectsForTenantAndAccessLevel "use" — every tenant member at use or above. Both are cert-manager kubernetes.io/tls Secrets carrying tls.key: -ca-tls is the CA secret (isCA: true) so its tls.key is the CA private key; -tls is the leaf with the server private key.
Evidence: certmanager.yaml CA secret (isCA: true) and leaf secret; Role rule at dashboard-resourcemap.yaml:18-25; RoleBinding at :39. RBAC is per-Secret and cannot scope to individual keys, so get exposes tls.key.
Impact: any tenant member can read the CA private key and mint certs the whole --ssl-ca trust chain accepts (MITM every MariaDB client), or read the server key to impersonate the server / decrypt non-PFS sessions.
Fix: remove both -ca-tls and -tls from the Role. If tenants need the trust bundle, publish only ca.crt to a separate ConfigMap (e.g. via trust-manager) or a ca.crt-only Secret, and grant read on that. tls.key must never reach a use-level subject. This is the same defect as the sibling rabbitmq/mongodb TLS PRs — a single shared fix (ca.crt-only trust anchor) would close it across the series.

Non-blocking follow-ups

  1. cozyrds/mariadb.yaml keysOrder lists ["spec","tls","required"] but not ["spec","tls","enabled"], so the dashboard can't toggle tls.enabled. This is a faithful regeneration (the field is a nullable *bool with no default) and matches the rest of the TLS series — a cross-PR follow-up if dashboard toggling is intended, not a mariadb-specific blocker.
  2. Inter-node and SAN coverage is thorough and matches the operator's -internal headless service; no SAN gap once B1's flag is corrected.

@github-actions github-actions Bot removed the size/XL This PR changes 500-999 lines, ignoring generated files label May 28, 2026
The CA source name was rewritten to a literal release prefix on the
premise that no release variable exists in the template context. That
premise came from the lineage webhook, which renders a different field:
resourceNames in the secrets selectors, where the context really is only
kind, name and namespace.

caCert.sourceSecretName is rendered by the CA-extraction controller
instead, and that one supplies a release variable explicitly. It also
parses with missingkey=error, so an undefined key would fail loudly
rather than resolve to an empty string — the silent collapse the rewrite
was meant to prevent could not have happened.

Restore `{{ .release }}-ca-bundle`. It is also the better spelling on its
own: the literal hardcodes an assumption about the release prefix and
would point at a Secret that does not exist if that prefix ever changed,
while the variable is the release name by construction. The reference
implementation for this field spells it the same way.

Drop the guard that forbade the variable. The controller's own tests pin
that spelling as correct, so the guard would have started failing the
canonical form the moment the field became live.

Keep the test pinning the source name, and say in it which renderer this
field belongs to, so the two contexts are not conflated again.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
The guard asserting that the trust anchor is surfaced to the tenant
grepped the whole file. The bundle is also named in a comment and in the
caCert declaration, so either of those could satisfy it while the
secrets.include entry it exists to protect was gone. Scope it to the
include block, the way the sibling key-bearing-Secret guard already does.
Verified by deleting the include entry with a matching string left
elsewhere in the file: the guard now fails, where before it passed.

The suite header described a comment as stating that spec.tls.issuer is
true. There is no such field on the MariaDB CR — issuer is a chart value,
and what the chart renders is spec.tls.enabled, which is what the rest of
that sentence already describes. Name the field the assertions actually
check.

Also drop the claim that an 11.4 client verifies by default. The default
that changed in 11.4 is the --ssl-verify-server-cert flag, not whether
verification happens: without --ssl-ca that client has no trust anchor,
and falls back to an anti-tampering check derived from the password hash
which establishes neither the chain nor the hostname, and which cannot be
derived at all for a passwordless user. Passing both flags is what gives
chain and hostname verification, on either client version. The documented
command was already correct; the sentence around it invited dropping the
flag that carries it.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
The backstop guards grepped the whole file for each name. Both lists sit
at the same indentation, so an entry in `include` satisfied a guard that
exists to prove the name is in `exclude` — and moving a name between them
is the single most dangerous edit this file allows, since exclude is what
keeps a Secret away from the tenant.

Moving `-ca`, the operator's CA private key, from exclude to include left
all ten guards green. So did moving `-root`. Extract the exclude block and
match inside it; both mutations now fail on the name that moved, and the
suite is unchanged otherwise.

Also correct two descriptions the TLS work outdated. The cleanup hook's
Role comment still said the rule covered two operator-generated Secrets;
it now covers four, and two of them are issued by cert-manager, which the
block above already explains. The runtime message called them all
operator-generated for the same reason.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
Three assertions matched text that survived the mutation they exist to
catch.

The backup CA guard was pinned by three separate substrings: the two test
expressions and the message. Flipping its exit status to 0, or short
circuiting the condition with `false &&`, leaves all three intact and the
suite green — while the backup runs on with --ssl-ca pointing at a file
that is missing or empty, and in the exit-0 case reports success having
dumped nothing. Match the whole construct through its exit instead.

The exclude entries were checked by name without checking they still sit
under resourceNames. A nil resourceNames matches every name, so dropping
that key turns the backstop into a match-everything selector while each
name is still present in the block. Assert the key.

The leaf certificate pinned renewBefore but not duration. cert-manager
rejects a duration that does not exceed renewBefore, so pinning one of
the pair lets the other drift into a certificate that renders but cannot
be applied.

Each is verified by the mutation it was blind to: exit 1 to exit 0, a
short-circuited condition, a renamed resourceNames key, and an 8760h
duration shrunk to 1h all now fail the suite.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
…ledger

The backup CA case is called "checks the CA file exists and names it" but
only guarded the first half. Deleting the Secret name from the error left
the suite green, so the reason MYSQL_CA_SECRET is passed to the CronJob at
all — telling the operator which Secret to go look at, since the client
reports a missing CA without naming a file or its source — was unpinned.

The cozyrds suite header also counted its own coverage wrong. It claimed
two live tests and three placeholders; there are seven and four. The four
exclude guards are live and went unmentioned, and the case reading the
source key is inert like its neighbours but carried no placeholder marker.
That header is the file's honest-coverage statement, so an understated
ledger is the one error it cannot afford.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
The comment claimed the block is pruned on apply. It is not: helm-controller
builds its server-side-apply patch against the schema the API server
publishes, so an undeclared field fails that patch with "field not declared
in schema" before any request reaches the server, and the HelmRelease fails
as a whole.

The distinction is load-bearing. It is no install rather than a degraded
one, which makes landing the CRD field a mechanically forced prerequisite
rather than a sequencing preference.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
…pply

The rejection happens at the API server's field manager under Flux v2.8's
Server-Side Apply, not client-side in helm-controller before the request is
sent — the run's own error carries an ErrStatus with code 500, so the request
did arrive. Under v2.7's client-side patch the undeclared field was dropped
silently and the block was genuinely inert, which is where the earlier wording
came from; v2.8 turned the same shape into a failed apply.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
…by name

The dashboard Role and the ApplicationDefinition granted the operator's
key-free mariadb-<name>-ca-bundle Secret directly by name. Every other
converged engine (mongodb, postgres, nats, qdrant) instead renders a
TenantProjection that lifts ca.crt into the canonical <release>.tenant-ca
object, reached through core.cozystack.io/tenantsecrets rather than a
per-Secret grant.

Add templates/tenant-projection.yaml sourcing from the operator's
ca-bundle, drop the name grant from the dashboard Role and from the
ApplicationDefinition's secrets.include (the existing tenant-ca
matchLabels selector now has something to match), and remove the
ApplicationDefinition's caCert field, which named a field the
ApplicationDefinition CRD does not define and would have rejected the
whole object under server-side apply.

Update the README and the pinned RBAC tests to the new tenant-ca name.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
The file asserted that the ApplicationDefinition declares the CA source
itself -- sourceSecretName, sourceKey -- and that the operator's ca-bundle
is granted by name in secrets.include. The chart renders none of that any
more. The trust anchor now reaches the tenant as the key-free
"<release>.tenant-ca" projection: a TenantProjection sentinel in the chart
names the extraction source, and the ApplicationDefinition selects the
result on internal.cozystack.io/tenant-ca. Three assertions therefore
pinned fields the file no longer contains, and a fourth pinned a by-name
grant this design deliberately withholds.

Pin the grant surface instead, the way nats-rd and mongodb-rd do: compare
the whole value of secrets.include against the credentials Secret plus the
tenant-ca selector, so neither an added name nor an added label slips past
a per-dimension check, and assert that neither the credentials Secret nor
the projection is named in exclude, which is evaluated first and wins. The
source declaration stays pinned where it is rendered, by
packages/apps/mariadb/tests/tenant_projection_test.yaml, which compares
sourceSecretName against an exact key-free name -- a stronger guard than a
grep here could make, since this file never sees the chart.

Every assertion guarding a key-bearing Secret is kept.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
The publication guards asserted notExists on the labels
internal.cozystack.io/publish-ca-cert and its -release variant. Neither
name exists anywhere in the tree. The chart renders only
k8s.mariadb.com/watch into secretTemplate.labels, so a notExists on a
path no template can produce holds under every value, and the two cases
titled for a CA publication source could not fail for what they named.

The labels that decide tenant visibility are tenant-ca, which the
ApplicationDefinition's secrets.include selects on, and tenantresource,
which tenantsecrets serves on. Both are now asserted absent on each
key-bearing Certificate. Exclude-by-name in the ApplicationDefinition
stays the backstop behind them, not a substitute.

The documents are addressed by the Secret each Certificate writes plus
an explicit kind rather than by documentIndex, because an index follows
the slot: reordering the template would slide a labelled key-bearing
Certificate out from under the assertion and pass green. metadata.name
cannot serve, as the CA Certificate and the CA Issuer share the name
<release>-ca.

The operator-issuer case promised a label check its body never made; it
asserts only that the template renders nothing. Under that issuer no
Certificate exists to label, so zero documents is the whole statement
available, and the title now says that instead.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
… certificates

The comment sent the reader to certmanager_test.yaml, which this chart
does not have. Its cert-manager coverage — the rendering of the CA and
leaf Certificates under each tls.issuer value — lives in tls_test.yaml.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
…nager Secrets

The rule sold itself as least privilege while granting -ca-tls and -tls
under every tls.issuer, including operator, where neither Secret is ever
rendered. The comment gave no reason for that, which reads as an
oversight in a rule whose whole point is scope.

Unconditional is what the template's own logic supports, so the reason
is now written rather than the grant narrowed. The delete above is
unconditional too and leans on --ignore-not-found: a release issued by
cert-manager and later switched to tls.issuer: operator still has both
Secrets on the cluster, and uninstall renders the Role from the values
current at that moment. Gating the names on the issuer would drop the
grant for exactly that release, failing the delete on RBAC and leaving
the CA and server private keys in a namespace the tenant shares with its
other applications.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
The unit suite pins what the chart renders — the sentinel, its source
name, its key — from the same assumptions the chart itself encodes, so
the two agree by construction and neither can catch the other being
wrong. Only a live mariadb-operator says whether <release>-ca-bundle is
the right name, and only a live apiserver says whether the lineage
webhook promotes the projection to something a tenant can read. Both
converged engines already carry this step; mariadb had none.

The fixture sets no tls.issuer, which is the case worth covering rather
than a gap: TLS is always served and the projection template is ungated,
so the operator reconciles the source here too.

A negative satisfied by an absent label is also satisfied by a webhook
that stamped nothing all run, so the credentials Secret leads as a
positive control. For the same reason the key-bearing names are checked
for private key material before their absence from the registry is
required, the count of names that reached that check is asserted
non-zero, and the read must answer NotFound specifically: an RBAC denial
or an unavailable aggregated apiserver also makes it fail, and reading
that as "safely absent" is how the check would stop discriminating
without ever going red.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
The migration procedure was headed "the order that avoids downtime" and
closed by claiming step 3 is the only step that can refuse a connection.
Step 1 restarts the instance: under cert-manager the CR gains
serverCertSecretRef and serverCASecretRef, so the name of the Secret
projected into the pods changes, and a pod-template change rolls the
StatefulSet. The chart supports single-replica instances, and for those
a rolling restart is plain downtime.

The document already knew this in the other direction — the going-back
section warns that deleting the Secret before the operator has reissued
and rolled the StatefulSet leaves the next pod unable to start, and
prints the rollout-status command — so the two halves contradicted each
other.

Both claims are now scoped to what they are true of: the order still
matters, because it keeps enforcement behind the client migration, and
step 3 remains the only step that refuses a client by policy rather than
by restarting the server under it.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
Both suites matched only the Secret name list in the hook's script, so
replacing kubectl delete with kubectl get left all 106 cases green while
the CA private key and the server private key survived the release. The
verb is the claim these tests exist to make; the names alone are just
text that happens to appear in a shell script.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
The notContains element omitted optional: true, and contains compares
each list element whole rather than as a subset, so the element it
described equalled nothing the template can render. Mounting the server
key Secret as the backup CA volume — the exact substitution the
assertion names — left it green, and only the neighbouring positive
caught the mutation.

The comment two cases below already described this trap as handled,
which is how it survived being reintroduced.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
The exclude checks read .spec.secrets.exclude[].resourceNames[]?, which
sees only name-shaped entries. An exclude entry selecting by label was
therefore invisible to every one of them, and adding one revokes the
tenant's connection string and its trust anchor at once: matchName
returns true for every name when resourceNames is nil, and exclude is
evaluated before include and wins. The test that existed to stop exactly
that edit let it through.

The empty-selector scan keyed on the literal token matchLabels, so an
empty matchExpressions and a bare {} passed it anywhere in the file,
including under services, which the comment claimed the same pass
covered. Both are legal: the selector type inlines metav1.LabelSelector.

One more comment stated the mechanism backwards — an exclude entry that
lost resourceNames makes the backstop total rather than voiding it — and
the check under it required only that some entry carry the key, so an
added entry without one went unnoticed.

Comparing spec.secrets and spec.services whole closes all of it. The
comparison is against the rendered chart rather than the source file,
because templates/cozyrd.yaml globs cozyrds/* and ships every match:
reading one file leaves a second one, dotfiles included, granting
whatever it likes. A document count fails closed on that.

The two per-name tests are kept ahead of the comparison, subsumed but
more legible, because the runner stops at the first failure. The
block-extraction helper is gone: the cozytest runner injects return 0
before a column-zero brace, so its status was discarded there.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
The case titled "renders regardless of tls.issuer" set issuer: operator,
which is the chart default, so both sides of its claim were the same
side. Hiding the sentinel behind the cert-manager gate — the one edit
the title rules out — left it green.

Being ungated is the projection's entire contract, so each state is now
its own case: the default, operator, cert-manager, and an explicit null,
which the issuer helper treats as unset rather than as a spelling of the
default.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
@lexfrei Aleksei Sviridkin (lexfrei) removed the do-not-merge/hold Indicates that a PR should not merge because someone has issued /hold label Sep 2, 2026
The chart asks cert-manager for two Secrets and names them after the
release: "<release>-ca-tls" for the CA and "<release>-tls" for the leaf.
The release prefix is "mariadb-", so an application named foo produces
mariadb-foo-ca-tls, and an application named foo-ca produces
mariadb-foo-ca-tls as well. One release's CA private key and another
release's server private key claim the same object. Two sibling
applications in one tenant namespace are enough to hit it: whichever
cert-manager writes last owns the Secret, and each instance then serves
or trusts material issued for the other.

The suffix set is what makes it ambiguous. It holds both "-tls" and
"-ca-tls", and one is a tail of the other, so a hyphen leaves nothing to
say where the release name ends. A dot does: an application name is
validated as a DNS-1035 label and the release prefix as ^[a-z0-9-]*$, so
neither can contain one, and "<release>.ca-tls" and "<release>.tls" split
uniquely. Secret names are DNS-1123 subdomains, which admit the dot.

Only the two suffixes this chart chooses move. The Certificate and Issuer
object names keep the hyphen, because their suffixes are not a pair where
one is a tail of the other, and the operator-managed
"<release>-ca-bundle" that the trust-anchor projection reads is not this
chart's to rename.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
…esolves

Two claims the chart makes about itself stopped being true when the anchor
moved to a projection.

certmanager.yaml said the ApplicationDefinition names the operator's
key-free "<release>-ca-bundle" as the publication source. It does not: the
TenantProjection this chart renders names it, and the ApplicationDefinition
carries no name for it at all — it selects the published anchor by label.

The ApplicationDefinition's own include comment then listed the two
cert-manager Secrets among those the lineage webhook resolves to this
instance. The webhook resolves an object through ownerReferences, or
failing that the Flux helm label, and returns nothing when it has neither.
A cert-manager Secret has neither while enableCertificateOwnerRef stays
false, which is the upstream default this platform does not override, and
is the same fact the uninstall hook relies on when it deletes those Secrets
itself. What does resolve is the operator's own "-ca", "-server-cert",
"-client-cert" and "-ca-bundle". The two names stay in exclude, because one
setting flip is all it takes to make the sentence true.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
The issuer resolver maps anything that is not exactly "cert-manager" to
operator issuance, so a typo fails open: the instance keeps the operator's
CA and nothing at render time reports that the value was not understood.
The enum is the only thing that refuses it, and one kubebuilder marker
generates that enum into both the chart's values.schema.json and the
ResourceDefinition's copy, so a single lost marker opens both guards at
once.

The suite already pins the rejection for the sibling field on this object
and left this one, the direction that fails quietly, uncovered. Removing
the enum from the schema fails this case and no other.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
The clause claimed the ApplicationDefinition names neither Secret. It
names this one: "mariadb-<name>.ca-tls" sits in its secrets.exclude list,
and the bats guard pins it there. What is true is narrower — neither is
named as the source the anchor is published from — so say that and say
where the name does appear, or the sentence invites a reader to conclude
the ApplicationDefinition has no reference to the Secret at all.

Rewrap the SAN-list paragraph's one 124-column line back into the block it
sits in, which is otherwise at 78.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
…oves them

Both cleanup suites asserted the delete as
"(?s)kubectl delete secret -n \S+.*<name>". Under (?s) the dot matches a
newline, so the gap runs past the end of the command: any earlier
"kubectl delete secret" satisfies the opening and the names may then sit
on any later command in the script. The pattern never established that
they are arguments of the delete at all.

Moving the two cert-manager Secrets off the delete onto a "kubectl get",
leaving the password delete above them untouched, keeps both suites green
while the CA private key and the server private key outlive the release
in a namespace the tenant shares with its other applications — the one
outcome this hook exists to prevent. The comments beside the assertions
already claimed the verb was pinned, so a reader trusting them had no
reason to re-check; they stand unchanged because the patterns now do what
they say.

Pin the whole invocation with its argument list instead, spelling out the
shell's line continuations rather than stepping over them. Whitespace and
backslashes are the only separators the pattern admits, and no command
can be written in those, so a match cannot leave the command it began in.
That mutation now reddens both suites, as does dropping one name from the
list, as does swapping the verb for a read.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

LGTM with non-blocking notes

Reviewed at b24ed36f0 against merge-base 3a70f9142. The base-to-head render diff is purely additive on every corner I rendered. The emitted CR shape is accepted by mariadb-operator 25.10.2's validating webhook, and the CA bundle the tenant anchor is lifted from does pick up the chart's CA. That leaves three MINOR craft items and one uncovered surface: no test starts an instance on the cert-manager path.

Findings

[MINOR] packages/apps/mariadb/templates/mariadb.yaml:125, multi-paragraph # prose ships into live objects

Six touched templates gain narrative # comments (mariadb.yaml 41 lines, certmanager.yaml 72, cleanup-pvc.yaml 29, dashboard-resourcemap.yaml 15, backup-cronjob.yaml 16, backup-script.yaml 16). Unlike {{/* */}}, these are copied verbatim into rendered manifests, so they land in the MariaDB CR, the dashboard Role and the cleanup Job in every customer cluster, and in the stored Helm release manifest. They also rot independently of the code beside them. Keep a one-line pointer and move the reasoning to the docs site.

$ grep -cE '^[[:space:]]*#' rendered-base/default.yaml rendered/head-default.yaml
rendered-base/default.yaml: 59
rendered/head-default.yaml: 99
$ grep -cE '^[[:space:]]*#' rendered/issuer-cm.yaml
175

[MINOR] packages/apps/mariadb/values.yaml:75, external's description does not mention its new certificate role

external now feeds the leaf certificate SAN list (certmanager.yaml:152-155) on top of switching the Service to LoadBalancer. Its @param text is unchanged, and cozyvalues-gen copies that text verbatim into values.schema.json, the README parameter table and the -rd openAPISchema the dashboard renders. An operator reading only the parameter table learns that external: true exposes the instance. The table does not tell them that under the default tls.issuer: operator the certificate cannot carry the external hostname, so --ssl-verify-server-cert from outside fails no matter which CA is trusted. The README says this; the field description, which is what the dashboard shows, does not.

[MINOR] packages/system/mariadb-rd/cozyrds/mariadb.yaml:75, the exclude backstop misses one enumerable operator credential

The comment above the list calls it the enumerable dangerous set. Against the operator's own key table at the pinned version, one credential Secret name is absent: mariadb-<name>-agent-auth, the generated basic-auth password for the Galera agent sidecar (api/v1alpha1/mariadb_keys.go:361, mounted at /var/run/secrets/mariadb-operator/agent). It is latent, not live. This chart never sets spec.galera (templates/mariadb.yaml emits replication only), so the Secret is never created today. <release>-backup-script is also outside the list; it holds the script and no credentials, so it is only a completeness point. Adding agent-auth now costs a line and removes the coupling between the backstop and a spec field someone may enable later.

$ yq eval-all 'select(.kind=="MariaDB") | .spec | keys' rendered/default.yaml | grep -c galera
0

Claim mismatches

[PARTIAL] "Merge is blocked on #3299, the CRD on main does not carry spec.caCert". Stale as of this revision. grep -rn caCert over the head tree returns only internal/controller/cacert internals; the branch emits a TenantProjection sentinel instead, and packages/system/cozystack-controller/definitions/internal.cozystack.io_tenantprojections.yaml already exists at the merge base (git cat-file -e succeeds there). Nothing in the diff needs a CRD field that main lacks.

[PARTIAL] "The tenant no longer receives <release>-ca-tls or <release>-tls". Against main those Secrets do not exist and were never granted: the base cozyrd is exclude: [] with include: [mariadb-{{ .name }}-credentials]. The sentence describes a delta against an earlier revision of this branch.

[PARTIAL] The "Fixes along the way" section (the --ssl-mode=VERIFY_IDENTITY flag, the leaf clientAuth usage) reads as fixes to shipped behaviour. Both are intra-branch: the base backup-script.yaml passes no SSL options at all, and no leaf certificate exists on main. Worth rewording so a reviewer diffing against main is not looking for code that was never there.

Caveats

  • The tls.issuer: cert-manager path has no live coverage, which the PR body states. I closed as much of it statically as the source allows. The webhook accepts a CR carrying both refs (internal/webhook/v1alpha1/webhook.go:26-49, the reject fires only when caSecretRef == nil && certSecretRef != nil). The operator skips issuance entirely when both shouldIssueCA and shouldIssueCert are false and certIssuerRef is nil (pkg/controller/certificate/controller.go:67-91), so it never contends with cert-manager for those Secrets. And TLSServerCASecretKey() returns ServerCASecretRef.Name when set (api/v1alpha1/mariadb_keys.go:92-107), so reconcileTLSCABundle folds the chart CA into <release>-ca-bundle and the tenant anchor does verify the served certificate. None of that proves an instance reaches Ready and completes a handshake. That is the check to run on a disposable cluster before the path is advertised.
  • The SAN list is hand-maintained and currently exact. I compared it against TLSServerDNSNames() at 25.10.2: ServiceNameVariants yields the same four depths for <name>, -primary and -secondary, HeadlessServiceNameVariants(meta, "*", "<name>-internal") the same four for the wildcard, plus localhost. The chart's cozy.local fallback matches both networking.clusterDomain (packages/core/platform/values.yaml:62) and the operator's clusterName (packages/system/mariadb-operator/values.yaml). An operator bump can silently break this, and as the template says, the unit suite compares the list against a copy of itself.
  • Applying the new spec.tls block onto MariaDB objects that already exist was reasoned, not executed (no live cluster). The values are identical to what the operator's mutating webhook already stores: SetDefaults writes &TLS{Enabled: true} when spec.tls is nil (api/v1alpha1/mariadb_types.go:835-839), and IsTLSRequired derefs a nil Required to false (:923-930), so required: false is the value already in effect. No field changes value, but live apply was not replayed.
  • chart_lint reports one render error for packages/apps/mariadb. It is a bootstrap stub artifact: the harness passes _cluster as a sequence, so cozy-lib.images-registry fails on index. With _cluster as a map the chart renders clean on every corner below.
  • review-helper upstream-src cached the pinned operator tree outside the review prefix, at /var/folders/.../T/cozy-upstream/mariadb-operator-mariadb-operator-25.10.2. Extend the cleanup command if you want it gone.

Checked and correct

  • Upgrade from N-1, rendered rather than reasoned. git worktree at the merge base against head, four corners (default, external=true, replicas=1, backup.enabled=true): the only identity delta on each is + TenantProjection/mariadb-test-ca. Nothing renamed, nothing removed, no immutable break.
  • Fresh install. cert-manager is a hard dependency of the operator's own PackageSource (packages/core/platform/sources/mariadb-operator.yaml lists cozystack.cert-manager) and the system bundle emits it, so the Issuer and Certificate CRDs are served before any instance renders.
  • Configuration matrix. Eleven corners rendered: issuer unset / operator / cert-manager, crossed with external, required, replicas=1, backup.enabled=true, and tls: null. Every one renders except issuer=cert-manager, external=true, _namespace=null, which is the intended required guard, and _namespace.host is always emitted (packages/apps/tenant/templates/namespace.yaml:125), so that guard is not operator-reachable.
  • Test non-vacuity. Thirteen mutations, each in its own checkout, all covered: dropping serverCASecretRef, hardcoding required: false, removing the dot from the leaf secretName, re-adding client auth, removing the cert-manager names from the cleanup delete, dropping --ssl-verify-server-cert, repointing the projection source at <release>-ca, inverting the helper default, dropping the external SAN, dropping the watch label, and three on the cozyrd (dropping .ca-tls from exclude, adding an unrestricted include, adding a label-only exclude). No gaps.
  • make generate is a no-op on this tree: git status --porcelain is empty after running it, so values.schema.json, the README table, the -rd openAPISchema and keysOrder are all current.
  • No reconcile amplifier. No checksum/* annotation is added, and the operator's bundle builder dedupes by certificate ID (pkg/pki/pem.go:87-102), so -ca-bundle is stable across reconciles even though it reads itself.
  • Certificate rotation gets less frequent, not more. The chart's leaf is duration: 8760h with renewBefore: 720h; the operator's own default is DefaultCertLifetime = 3 months at 33% renew-before (pkg/pki/x509.go:15-16, pkg/pki/renewal.go:9). Since the pod annotation hashes the server certificate (internal/controller/mariadb_controller_tls.go:207-218), an operator-issued instance already rolls roughly every two months; moving to cert-manager rolls it less often.
  • tls.required: true does not strand the exporter. The operator writes tls=true plus the client cert into the exporter my.cnf whenever IsTLSEnabled() (internal/controller/mariadb_controller_metrics.go:125-150), which is already true today.
  • The backup CronJob really cannot start. Its MYSQL_ROOT_PASSWORD reads secretKeyRef.name: {{ .Release.Name }}, the operator's root Secret is <name>-root (api/v1alpha1/mariadb_keys.go:13-23), and the rendered chart emits only -backup-script, -backup and -credentials. The new exit 1 on a missing CA therefore reaches no running path.
  • The mechanical anti-pattern sweep is clean on the diff: no || true / || : / 2>/dev/null on a decision command in the new shell, no mutating verb granted cluster-wide (the cleanup Role stays namespaced and name-scoped), and the new bats fixtures round-trip through the real yq/jq.
  • E2E selection reaches this change: hack/select-e2e.sh on the changed-file list emits the full suite, mariadb included, so the new chainsaw block runs.
  • No mutable-identity hazard. tls.issuer gates whether the Certificate and Issuer objects render; it never feeds a metadata.name, a selector, or a content hash, so flipping it prunes only cert-manager objects and leaves no data-bearing resource to be re-identified.

Recommended follow-ups

  • Run cozystack-pr-test on a disposable cluster with tls.issuer: cert-manager, both replicas=1 and replicas=2, and confirm the pods reach Ready and a client with the published anchor completes a verified handshake. That is the one surface no static check reaches.
  • Add mariadb-<name>-agent-auth to the cozyrd exclude list so the backstop stays complete if Galera is ever enabled.
  • The comment at templates/backup-script.yaml says a fully expired CA set yields "a present but empty ca.crt". BundleCertificatePEMs returns errors.New("no certificate PEMs were found") when every certificate is skipped (pkg/pki/pem.go:65-67), so the reconcile fails and the Secret keeps its previous content rather than going empty. The -s test is still worth keeping; the stated reason is not what happens.

@kvaps Andrei Kvapil (kvaps) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. This lands the CA-consume contract the way #2811 settled it, and the two things I went looking for as risks both turn out to be handled deliberately.

The trust anchor follows the agreed shape. A TenantProjection of type: CACert over a key-free source, and the tenant reads it through the label-gated core.cozystack.io/tenantsecrets path rather than by name. The choice of <release>-ca-bundle as the source is the part worth keeping: it is already key-free, it exists under both tls.issuer values where a cert-manager Secret does not, and it carries the previous CA alongside the new one across a rollover, so the tenant reads one stable object regardless of which issuer is active. That is the CA-agnostic consume contract rather than a homogenised CA.

No TLS Secret is granted in the resourcemap, key-bearing or not — including -ca-bundle, on the grounds that a name grant conveys whatever occupies the name while the label path conveys only what the platform vouched for. That reasoning matches the mongodb chart almost word for word, which is what I want: one argument, applied the same way in both places.

TLS being served under both issuers is not a behaviour change, and the comment explains why in the one place it matters. mariadb.yaml sends an explicit tls.enabled: true because the operator substitutes an enabled spec only when spec.tls is absent entirely — so sending the block without it would have turned TLS off. That inverts the obvious reading of the diff, and it is the kind of thing that gets "simplified" later by someone who assumes the explicit true is redundant.

required is opt-in and typed to stay that way. Required *bool with no default keeps "unset" distinguishable from an explicit false, and the template's kindIs "invalid" guard preserves that distinction rather than collapsing it. The comment states that flipping it disconnects every client not yet on TLS and is therefore a decision rather than something an upgrade does, with the flip named as a future announced change. That sequencing is correct: serve first, enforce later, and never enforce as a side effect of an upgrade.

api/ is additive throughout — a new optional struct defaulting to {}, an enum defaulting to operator, and the pointer above. Existing MariaDB objects render unchanged.

CI is green including E2E on b24ed36f0.

One cosmetic note, not a request: the Go field is Tls where the repo's other initialisms would suggest TLS. The JSON tag is tls either way and the linter evidently does not enforce it, so this only matters if someone later turns that rule on.

@lexfrei
Aleksei Sviridkin (lexfrei) merged commit d800526 into main Sep 4, 2026
47 checks passed
@lexfrei
Aleksei Sviridkin (lexfrei) deleted the feat/tls-mariadb branch September 4, 2026 15:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/database Issues or PRs related to managed databases (postgres, mariadb, redis, etcd, kafka, clickhouse) kind/feature Categorizes issue or PR as related to a new feature size/XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants