fix: #3588828 DomainConfigOverrideMigration must not overwrite live collection entries
This MR fixes two distinct silent-data-loss paths in the 2.x → 3.x migration shipped by !378 (merged), plus three audit follow-ups.
Bug 1: stale-overwrite of live collection values
The original migrateConfigurations() wrote every legacy entry into its destination collection unconditionally. On any site that ran the original domain_config_update_10001() with its [a-z]{2} langcode regex, hyphenated and 3-letter langcode legacy rows (pt-br, zh-hans, nb-no, fil, …) were silently skipped at the time. Administrators on those sites will have re-overridden the same configurations through the UI in the months since, so the per-(domain, langcode) collections now hold live values for those configs. The current code's broader regex now matches those legacy rows -- and the unconditional $collection->write() calls would stomp the live UI-set collection values with the stale 2.x payloads on the next drush updatedb.
Fix: guard each per-domain or per-(domain, language) write with $collection->exists($config_name). If a value is already there, do not overwrite it -- record the legacy name under a new conflicts key in the result array and continue. Conflicting legacy rows are intentionally not added to $results['migrated'], so the cleanup pass at the end of migrateConfigurations() does not delete them. The legacy row stays on disk so the administrator can compare its payload against the live collection value via drush config:get / config export and decide whether to keep, merge, or discard. domain_config_update_10002()'s return message lists the first 10 conflicts inline (with "...and N more" if there are more) and logs the full list to the watchdog under the domain_config channel.
Bug 2: 3+ segment base config names silently stranded
The original migration regex captured a fixed [^.]+\.[^.]+ tail and silently dropped any legacy entry whose base config name had more than 2 dot-separated segments. This is not limited to manual overrides on config entities -- core ships simple configs that match the same shape:
system.theme.global(global theme settings, served at/admin/appearance/settingsbySystemThemeSettingsForm extends ConfigFormBase) -- 2.0.x'sdomain_config_uitoggle was offered on this form like on any other simple-config form.system.image.gdand similar core simple configs.
So the affected population is any 2.x multi-domain site that used the standard UI to override system.theme.global (or similar) per domain. Those overrides have been on disk as domain.config.{domain_id}.system.theme.global ever since, and the original 3.x migration silently left them there. Entity-config overrides (block.block.X, views.view.X, core.entity_form_display.X.Y.Z, …) were also affected; those came from manual paths (the 2.x UI never offered the toggle on entity forms).
Fix: replace the regex split with a payload-then-langcode-peel:
- Strip the
domain.config.{domain_id}.prefix. - Look at the first dot-segment of the remaining payload. If it looks like a langcode (
[a-z]{2,3}(-[a-z0-9]+)*) AND is in the installed-languages list, peel it as the langcode -- the rest is the destination's base config name verbatim, no shape constraint. - If it looks like a langcode but the language is NOT installed, leave the legacy row in place for admin review.
- Otherwise (4+ char first segment, e.g.
system,views,block) the whole payload is the base config name (no langcode prefix). - Reject single-segment payloads (no dot in the resulting base config name) -- those are not valid Drupal config names.
The shape-then-installed disambiguator preserves the existing "unknown language → leave legacy on disk" behaviour while letting real-world 3+ segment overrides through.
Audit follow-ups
- Stale docblock reference fixed (
rebuildOverridableConfigurations()→addOverridableConfigurationsFromCollections()). domain_config_update_10002()caps the inline conflict list to 10 names with "...and N more"; full list goes to the watchdog. Avoids multi-KB update messages.- Result shape grows a
conflictsfield next tomigratedanderrors.
Test coverage
17 cases in DomainConfigOverrideMigrationTest, 100 assertions:
- Per-domain entry; per-(domain, language) entry; hyphenated langcode regression; unknown language stays in place; no
domain_config_ui.settingswrite; no-op when storage is empty. - Per-domain conflict; per-(domain, language) conflict (the
pt-brcase that brought this to attention); mixed migrated + conflict in one run. system.theme.globalas a 3-segment SIMPLE config exercised end-to-end.- 3-segment entity-config (
block.block.example_block) on per-domain and per-(domain, language) paths; deeply-nested 5-segment base name (core.entity_form_display.node.article.default). - Langcode-shaped first segment that is NOT installed leaves legacy in place; module-named first segment (6 letters) is not mistaken for a langcode; conflict guard fires on entity-config names too; single-segment payload skipped.
Caveat
This MR only prevents future damage. On sites that already ran domain_config_update_10002() from !378 (merged) before this lands, conflicts have already been resolved in favour of the stale legacy value, and the legacy row was deleted in the cleanup pass -- there is no on-disk record of the prior live collection value. Recovery requires a config / DB backup taken before drush updatedb.