sec(#947): chart-manage the mysql-client root password (rotate off the baked literal) - #822
Conversation
…e baked literal) backend#947 / backend#1528 Phase 0: enable rotating the mysql-client root password off the image's baked literal. New rotateMysqlRoot gate (default off, ByEnv-resolved like serviceDbAccounts / bootstrapDbReparent): when on, the chart generates a random root password into the Secret (3-tier: pin -> existing Secret -> randAlphaNum, mirroring credmgr) and injects it as MYSQL_ROOT_PASSWORD on the mysql-client deployment. Scope — this rotates a FRESH datadir only. The mysql entrypoint reads MYSQL_ROOT_PASSWORD at init and ignores it thereafter, so an existing edge keeps its current root password until the one-time rollout DDL runs (ALTER USER 'root'@'%'/'@localhost' IDENTIFIED BY '<the generated Secret value>', via kubectl exec as root@localhost). The chart deliberately does not run that ALTER: it would need to authenticate as root with the current literal (re-introducing it) or hit a chicken/egg once rotated — so, like the rest of the epic, the live-fleet DDL stays an ops step. Wiring this Secret into #785's bootstrapDbPassword is a deliberate follow-up (one self-contained change per PR; don't churn just-merged code). - values.yaml: rotateMysqlRoot + rotateMysqlRootByEnv{dev,stg,prod: false} + mysqlRootPassword pin. Heavy comment on the fresh-vs-existing distinction and the ALTER rollout step. - _helpers.tpl: tracebloc.rotateMysqlRoot (mirrors serviceDbAccounts resolution). - secrets.yaml: MYSQL_ROOT_PASSWORD 3-tier generate-and-persist, gated. - mysql-deployment.yaml: MYSQL_ROOT_PASSWORD env from the Secret, gated; probes use `mysqladmin ping` (no auth) so they are undisturbed. - values.schema.json: three keys. Chart.yaml 1.9.68 -> 1.9.69 (+ appVersion). - tests: +7 helm-unittest cases (secrets off/on/pin/byEnv/placeholder; deployment env off/on). Additive and default-off: helm unittest 569 passed, lint clean, default render byte-identical (0 MYSQL_ROOT_PASSWORD refs), gate-on injects a 48-char random password (not the literal). No literal in the diff. Refs: backend#947 (rotate + remove hardcoded creds), backend#1528 (Phase 0), client#785 (re-parent this rotation feeds). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
saadqbal
left a comment
There was a problem hiding this comment.
Approving — and the scope statement is the best part of this. Saying plainly that the chart rotates a fresh datadir only, that it deliberately does not run the ALTER USER, and why (authenticating as root with the current literal would re-introduce it, or hit a chicken/egg once rotated) is worth more than a fix that quietly implied the exposure was closed. Same for flagging that the gate flip rolls the pod, and that any tooling dialling root with the old literal breaks at rotation.
Checked the things that would hurt on a public repo or a live edge:
Generate-once is real. $existingSecret at secrets.yaml:17 is a genuine lookup, so tier 2 preserves the value across upgrades — no randAlphaNum-on-every-upgrade trap locking MySQL out of its own datadir.
The Secret key and the secretKeyRef are gated by the same helper, so they appear and disappear together. A default install can't end up with a deployment referencing a key the Secret didn't emit.
Nothing shipped. values.yaml carries mysqlRootPassword: "", not a default, and the only literals in the diff are helm-unittest fixtures in tests/secrets_test.yaml. The placeholder guard on the pin is a nice touch — <ROOT_PASSWORD> failing loudly beats it reaching MySQL.
--reuse-values lands safe. rotateMysqlRoot is unset-vs-false aware via kindIs "invalid", and both it and rotateMysqlRootByEnv are default dict-guarded — so an upgrade that carries stored values without the new keys resolves to gate-off rather than crashing templating or half-rotating.
To be precise about what this does and doesn't close: it's the enabling half. The literal is still in public history and every existing edge still has it live until the rollout DDL runs, which is exactly how the body frames it.
…otates it migrate-tenant.sh already requires MYSQL_ROOT_PW (no hardcode); this just tells the operator that on a fleet with rotateMysqlRoot on, that value is the mysql-client Secret MYSQL_ROOT_PASSWORD, not the image-baked legacy password. Comment-only, lands with the rotation feature it describes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The operational half of rotateMysqlRoot: read the generated Secret value, run the one-time ALTER USER root (both hosts) via kubectl exec, verify old-pw-dead / new-pw-works / heartbeat-full-count, roll back by re-ALTERing. Documents the consumer inventory that must move to the Secret first and the prod exec-access gap. Lands with the feature it operationalizes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The runbook interpolated NEWPW into the sh -c string, so the ALTER USER text showed in the node ps output — the exact leak the runbook warned against. Rewritten so every secret travels only over the exec stdin stream: auth via a mode-600 --defaults-extra-file written+deleted in the pod, the new password via mysql stdin. Nothing in any process argv. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
bugbot run |
…ql-root # Conflicts: # client/Chart.yaml
The remote sh -s body had no set -e and ended in rm -f, so a failed ALTER USER still exited 0 — kubectl exec reported success while root stayed on the old password and the Secret diverged. Now: set -e + an EXIT trap that always wipes the password file; the rotate and new-pw-verify blocks exit non-zero on failure, and the old-pw-verify inverts explicitly (OK only when the old pw is rejected). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
bugbot run |
…(Bugbot) Two related High findings on the runbook snippets: - every mysql call used the default unix socket, but this image puts the socket at /var/lib/mysql/mysql.sock (the reason the probes are pinned to -h 127.0.0.1) so the ALTER/verifies could not reach mysqld. Added host=127.0.0.1 to every --defaults-extra-file. - the old-password verify treated ANY non-zero mysql exit as \"rejected\", so a connection error (mysqld restarting, 2003) read as success. It now requires ERROR 1045 (access denied) to pass and flags anything else INCONCLUSIVE. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
bugbot run |
…gbot)
An empty NEWPW (wrong NS/REL, or rotateMysqlRoot not actually enabled so the
Secret lacks MYSQL_ROOT_PASSWORD) would ALTER root to an EMPTY password while the
runbook printed success. Guard both CURPW and NEWPW non-empty before building any
ALTER, using ${var:?} so it fails fast but is safe to paste interactively — same
fail-fast intent as migrate-tenant.sh MYSQL_ROOT_PW guard. Preserves the earlier
fixes (argv-safe stdin, set -e/trap, 1045-specific verify, TCP host).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
bugbot run |
The chart documented mysqlRootPassword as \"never interpolated into DDL\", but the rotation runbook this PR adds embeds it in ALTER USER ... IDENTIFIED BY '...'. A pin with a quote or metacharacter (which the chart allowed) would break the one-time ALTER; generated randAlphaNum happened to be safe. Reconciled by enforcing [A-Za-z0-9]+ on the pin, exactly like the credmgr/tb_meta/tb_ingest pins (which are constrained for the same CREATE USER DDL reason). bootstrapDbPassword stays unconstrained — it is only ever a connection parameter, never DDL. Updated values.yaml + schema wording and added a reject test.
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 3693c57. Configure here.
saadqbal
left a comment
There was a problem hiding this comment.
Re-approving. My earlier approval was dismissed by the pushes since; I've read all five.
The alphanumeric constraint is the one I looked at hardest, because narrowing a password charset is normally a step backwards. Here it isn't: the value lands inside ALTER USER … IDENTIFIED BY '…' in the rotation runbook, so a quote or shell metacharacter breaks the one-time rotation — and it breaks it mid-way, on the operation you least want half-applied. Rejecting it at template time beats discovering it in a DDL statement.
What makes it right rather than merely defensible is the schema note drawing the line explicitly: constrained here, not constrained for bootstrapDbPassword, because that one is only ever a connection parameter and this one also lands in DDL. Correcting the old "NOT constrained to alphanumeric" sentence in the same change matters too — that claim was about to become false, and it's precisely the sentence a future operator would have relied on. randAlphaNum already satisfying the constraint means the generated path is unaffected, so this only ever fires on an operator pin.
The three runbook commits are the same instinct applied to the procedure: failing loudly on a failed ALTER, asserting 1045 on the old-password check rather than assuming the negative, and refusing an empty root password. A rotation runbook that continues past a failed step is worse than one that stops.
20 checks green, no open threads.
What
backend#947 / backend#1528 Phase 0. Enable rotating the
mysql-clientroot password off the image's baked literal. A newrotateMysqlRootgate (default off, ByEnv-resolved exactly likeserviceDbAccounts/bootstrapDbReparent): when on, the chart generates a random root password into the Secret (3-tier: pin → existing Secret →randAlphaNum, mirroring credmgr) and injects it asMYSQL_ROOT_PASSWORDon the mysql-client deployment.This is the credential the #785 re-parent points the mint at, and — separately — a literal now disclosed in public git history, so rotating it is both a Phase-0 prerequisite and a standing-exposure fix.
Scope — read this, it's the important part
This rotates a FRESH datadir only. The mysql entrypoint reads
MYSQL_ROOT_PASSWORDat first init and ignores it thereafter, so an existing edge keeps its current root password until the one-time rollout DDL runs:(read the value from the Secret, run via
kubectl execasroot@localhost). The chart deliberately does not run thatALTER— it would have to authenticate as root with the current literal (re-introducing it) or hit a chicken/egg once rotated. Consistent with the rest of #1528, the live-fleet DDL stays an ops step; the chart only provides the Secret + the fresh-init override.Turning the gate on rolls the mysql pod (an env change → brief restart), so flip it in a window. And any tooling/runbook that dials root with the old literal must move to the Secret first, or it breaks at rotation — that inventory is the substance of #947.
Changes
values.yaml—rotateMysqlRoot(override) +rotateMysqlRootByEnv{dev,stg,prod: false}+mysqlRootPasswordpin, with the fresh-vs-existing distinction and theALTERstep documented inline._helpers.tpl—tracebloc.rotateMysqlRoot, resolving likeserviceDbAccounts.secrets.yaml—MYSQL_ROOT_PASSWORD3-tier generate-and-persist, gated. No alphanumeric constraint (connection param, never DDL); placeholder-guarded.mysql-deployment.yaml—MYSQL_ROOT_PASSWORDenv from the Secret, gated. Probes usemysqladmin ping(needs no auth), so they're undisturbed.values.schema.json— three keys.Chart.yaml1.9.68 → 1.9.69 (+appVersionlockstep).Design notes
ALTERit; here the chart owns the value, so generate is safe and better.)bootstrapDbPasswordwiring is a follow-up — kept out of scope so this is one self-contained change and feat(#1528): wire DB_BOOTSTRAP_* into jobs-manager to re-parent the mint off edgeuser (S3) #785 (merged hours ago) isn't churned. During rollout the operator pointsbootstrapDbPasswordat this Secret's value; a later PR can unify them.Verification
helm unittestfull suite: 569 passed (34 suites).helm template(ci/eks base): default render = 0MYSQL_ROOT_PASSWORDrefs (byte-identical); gate-on injects a 48-char random password (decoded, confirmed not the literal) into the Secret and the deployment env;rotateMysqlRootByEnv.dev=trueresolves on.helm lintclean; version-bump guard satisfied; noEdg9@Tr@cein the diff.Not in this PR (the rest of Phase 0 / #947)
ALTER USER 'root'rollout (ops, reads this Secret).Dockerfile.mysql_clientENV source scrub (gated on the client#454 image freeze; the runtime override here neutralizes the baked default for new installs meanwhile).Refs: backend#947 · backend#1528 (Phase 0) · client#785 (the re-parent this feeds).
🤖 Generated with Claude Code
Note
Medium Risk
When enabled, mis-rotation or flipping the gate without the runbook can lock out root consumers or briefly roll mysql; default-off limits blast radius but fleet rollout is credential-sensitive.
Overview
backend#947 / backend#1528 Phase 0 adds an opt-in path to stop relying on the
mysql-clientimage’s baked-in root password. A newrotateMysqlRootgate (default off, resolved likeserviceDbAccountsviarotateMysqlRootByEnv) controls whether the chart owns root credentials.When the gate is on, the release Secret gets
MYSQL_ROOT_PASSWORD(pin → existing Secret →randAlphaNum), and the mysql-client Deployment reads it asMYSQL_ROOT_PASSWORD. That only applies on a fresh datadir; live edges still need a one-time opsALTER USER 'root'using the Secret (documented indocs/migration-tools/rotate-mysql-root.md). Default installs stay byte-identical (no env block, no Secret key).Also documents
MYSQL_ROOT_PWformigrate-tenant.shwhen root is chart-managed, extendsvalues.yaml/values.schema.json, bumps the chart to 1.9.71, and adds helm-unittest coverage for the gate and password validation.Reviewed by Cursor Bugbot for commit 3693c57. Bugbot is set up for automated code reviews on this repo. Configure here.