@@ -46,11 +46,12 @@ value.
4646
4747### Bug 2 — Root password not configurable
4848
49- - [ ] Add an optional ` root_password ` field to the MySQL section of the environment
49+ - [x ] Add an optional ` root_password ` field to the MySQL section of the environment
5050 configuration JSON schema
51- - [ ] When a root password is provided by the user, use it; when omitted, generate a
52- strong random password at render time rather than deriving it from the app password
53- - [ ] Remove the ` format!("{password}_root") ` derivation from ` create_mysql_contexts `
51+ - [x] When a root password is provided by the user, use it; when omitted, generate a
52+ strong random password at environment creation time (application layer) rather
53+ than deriving it from the app password
54+ - [x] Remove the ` format!("{password}_root") ` derivation from ` create_mysql_contexts `
5455
5556### Bug 3 — Reserved username not rejected
5657
@@ -174,21 +175,44 @@ no `root_password` field for MySQL. The deployer therefore always sets
174175
175176** Fix** : Add an optional ` root_password ` to the MySQL section of the environment
176177configuration JSON. If the user provides it, use it. If they omit it, generate a
177- cryptographically random password at render time instead of deriving it from the app
178- password. Remove the ` format!("{password}_root") ` derivation.
178+ cryptographically random password at ** environment creation time** (application layer)
179+ instead of deriving it from the app password. Remove the ` format!("{password}_root") `
180+ derivation.
181+
182+ ** Key design decision — generate at creation time, not render time** : The root password
183+ is a domain invariant that must remain stable across multiple renders (e.g. re-deploying
184+ without reprovisioning). Generating it at render time would produce a different
185+ ` MYSQL_ROOT_PASSWORD ` on each render, breaking MySQL container restarts. Instead,
186+ generation happens once in the application layer (` TryFrom<DatabaseSection> ` ) when the
187+ environment is first created, and the value is persisted alongside the rest of the
188+ environment config.
179189
180190** Affected modules and types** :
181191
192+ - ` Cargo.toml ` : add ` rand = "0.9" ` dependency.
182193- ` schemas/environment-config.json ` : add optional ` root_password ` string to the MySQL
183194 database object.
184- - ` src/domain/tracker/config/core/database/mysql.rs ` (` MysqlConfig ` ): add optional
185- ` root_password ` field; update constructor and accessors.
186- - ` src/presentation/cli/controllers/create/subcommands/environment/config_loader.rs `
187- (or equivalent deserialization path): propagate the optional field through to the
188- domain type.
195+ - ` src/shared/secrets/random.rs ` (new file): ` generate_random_password() -> Password `
196+ using ` rand::rng() ` (ThreadRng seeded from OsRng), guaranteeing one character from each
197+ class (lower, upper, digit, symbol), filled to 32 characters, then shuffled. Satisfies
198+ MySQL ` validate_password MEDIUM ` policy.
199+ - ` src/shared/secrets/mod.rs ` and ` src/shared/mod.rs ` : re-export
200+ ` generate_random_password ` .
201+ - ` src/domain/tracker/config/core/database/mysql.rs ` (` MysqlConfig ` ): ` root_password `
202+ field is ` Password ` (non-optional) — the domain type always has a value. Constructor
203+ ` new() ` takes ` root_password: Password ` . Accessor ` root_password() -> &Password ` added.
204+ ` MysqlConfigRaw ` (the serde deserialization intermediate) keeps
205+ ` root_password: Option<Password> ` with ` #[serde(default)] ` for backward compatibility
206+ with persisted environments that pre-date this field; missing values are filled by
207+ calling ` generate_random_password() ` during deserialization.
208+ - ` src/application/command_handlers/create/config/tracker/tracker_core_section.rs `
209+ (` TryFrom<DatabaseSection> ` ): generation happens here — the optional user-supplied
210+ ` root_password ` is mapped to ` Password ` if present, or ` generate_random_password() ` is
211+ called if absent. This is the single point of generation for new environments.
189212- ` src/application/services/rendering/docker_compose.rs ` (` create_mysql_contexts ` ):
190- replace ` format!("{password}_root") ` with either the user-supplied root password or a
191- freshly generated random password.
213+ ` root_password ` parameter is now ` PlainPassword ` (non-optional); call site passes
214+ ` mysql_config.root_password().expose_secret().to_string() ` . No generation logic
215+ remains here.
192216
193217### Bug 3 — Reserved MySQL Username ` "root" ` Not Rejected
194218
@@ -237,24 +261,27 @@ Tasks are ordered from simplest to most complex.
237261
238262### Phase 1: Reject reserved MySQL username (Bug 3)
239263
240- - [ ] In ` MysqlConfigError ` (` mysql.rs ` ): add ` ReservedUsername ` variant
241- - [ ] Add ` help() ` arm for ` ReservedUsername ` with actionable fix instructions
242- - [ ] In ` MysqlConfig::new() ` : add ` if username == "root" ` guard returning
264+ - [x ] In ` MysqlConfigError ` (` mysql.rs ` ): add ` ReservedUsername ` variant
265+ - [x ] Add ` help() ` arm for ` ReservedUsername ` with actionable fix instructions
266+ - [x ] In ` MysqlConfig::new() ` : add ` if username == "root" ` guard returning
243267 ` Err(MysqlConfigError::ReservedUsername) `
244- - [ ] Add unit test ` it_should_reject_root_as_username `
268+ - [x ] Add unit test ` it_should_reject_root_as_username `
245269
246270### Phase 2: Make root password configurable (Bug 2)
247271
248- - [ ] ` schemas/environment-config.json ` : add optional ` root_password ` string to the
272+ - [x ] ` schemas/environment-config.json ` : add optional ` root_password ` string to the
249273 MySQL database object
250- - [ ] ` MysqlConfig ` (` mysql.rs ` ): add optional ` root_password ` field; update constructor
251- and accessor
252- - [ ] Deserialization/config-loading path: thread the optional field through to the
253- application layer
254- - [ ] ` create_mysql_contexts ` (` docker_compose.rs ` ): replace
255- ` format!("{password}_root") ` with user-supplied value or a randomly generated
256- password (use ` rand ` / ` getrandom ` — already in ` Cargo.toml ` — to produce a
257- 16+ character alphanumeric string)
274+ - [x] ` MysqlConfig ` (` mysql.rs ` ): ` root_password ` is ` Password ` (non-optional) in the
275+ domain — always has a value. ` MysqlConfigRaw ` uses ` Option<Password> ` for backward
276+ compat with persisted environments lacking the field.
277+ - [x] ` src/shared/secrets/random.rs ` (new): ` generate_random_password() -> Password `
278+ using mixed charset (lower + upper + digit + symbol), length 32, satisfies MySQL
279+ MEDIUM password policy
280+ - [x] ` TryFrom<DatabaseSection> ` (` tracker_core_section.rs ` ): generates root password at
281+ environment creation time — not at render time — so it is stable across re-renders
282+ - [x] ` create_mysql_contexts ` (` docker_compose.rs ` ): replaced ` format!("{password}_root") `
283+ with ` mysql_config.root_password().expose_secret().to_string() ` ; no generation
284+ logic remains here
258285
259286### Phase 3: Move DSN to env var override and add URL-encoding (Bug 1)
260287
@@ -308,13 +335,17 @@ Tasks are ordered from simplest to most complex.
308335
309336** Task-Specific Criteria — Bug 2 (root password)** :
310337
311- - [ ] The environment configuration JSON schema accepts an optional ` root_password ` field
338+ - [x ] The environment configuration JSON schema accepts an optional ` root_password ` field
312339 in the MySQL database object
313- - [ ] When ` root_password ` is provided in the env JSON it is used as ` MYSQL_ROOT_PASSWORD `
340+ - [x ] When ` root_password ` is provided in the env JSON it is used as ` MYSQL_ROOT_PASSWORD `
314341 in the rendered ` .env `
315- - [ ] When ` root_password ` is omitted, a randomly generated password is used — it is
342+ - [x ] When ` root_password ` is omitted, a randomly generated password is used — it is
316343 ** not** derived from the app password
317- - [ ] ` create_mysql_contexts ` no longer contains ` format!("{password}_root") `
344+ - [x] ` create_mysql_contexts ` no longer contains ` format!("{password}_root") `
345+ - [x] The random password is generated once at environment creation time (not at render
346+ time), ensuring stability across multiple renders
347+ - [x] The domain type ` MysqlConfig.root_password ` is always populated (` Password ` ,
348+ non-optional)
318349
319350** Task-Specific Criteria — Bug 1 (DSN in tracker.toml)** :
320351
0 commit comments