From 61d1141ebe441d07f8b6c6f33f93b0eb3ffcefeb Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Thu, 6 Aug 2026 11:36:01 -0500 Subject: [PATCH 1/2] Preserve LMDB databases across an on-disk format change LMDB 1.0 cannot read databases written by 0.9, and reports them as MDB_INVALID, which CFEngine treats as corruption and deletes. Export the databases in the preinstall script while the old mdb_dump is still installed, and import them again in the postinstall script. Ticket: CFE-4701 Changelog: LMDB databases in the state directory are now preserved when upgrading across an LMDB on-disk format change, instead of being discarded as corrupt --- deps-packaging/lmdb/README.md | 15 ++++ packaging/common/cfengine-hub/postinstall.sh | 3 + packaging/common/cfengine-hub/preinstall.sh | 10 +++ .../common/cfengine-non-hub/postinstall.sh | 3 + .../common/cfengine-non-hub/preinstall.sh | 8 ++ packaging/common/produce-script | 6 ++ .../common/script-templates/script-common.sh | 84 +++++++++++++++++++ 7 files changed, 129 insertions(+) diff --git a/deps-packaging/lmdb/README.md b/deps-packaging/lmdb/README.md index f3154ad15..0192e58b0 100644 --- a/deps-packaging/lmdb/README.md +++ b/deps-packaging/lmdb/README.md @@ -1,5 +1,20 @@ # Upgrading / patching LMDB +## Before changing the version: check the on-disk format + +LMDB has no in-place upgrade. If the new version writes a different on-disk +format, every existing `*.lmdb` becomes unreadable, and CFEngine takes that for +corruption and deletes it (ENT-9717). + +The format is stable within a `.` series and changed in 1.0, so a +patch bump is safe and a series bump is not. Series bumps are handled by +`lmdb_dump_databases()` / `lmdb_load_databases()` in +`packaging/common/script-templates/script-common.sh` (CFE-4701), which key off the +version in `source` below via `LMDB_VERSION`. A format change *within* a series +would need `lmdb_migration_needed()` made more specific. + +## Upgrading / patching + From the directory above buildscripts: ``` diff --git a/packaging/common/cfengine-hub/postinstall.sh b/packaging/common/cfengine-hub/postinstall.sh index 096647c0d..5dfe03328 100644 --- a/packaging/common/cfengine-hub/postinstall.sh +++ b/packaging/common/cfengine-hub/postinstall.sh @@ -40,6 +40,9 @@ if use_systemd; then fi fi +# CFE-4701: restore what preinstall exported, before anything uses the databases. +lmdb_load_databases || cf_console echo "Warning: importing the LMDB databases failed." + # # Make sure the cfapache user has a home folder and populate it # diff --git a/packaging/common/cfengine-hub/preinstall.sh b/packaging/common/cfengine-hub/preinstall.sh index bd56143e6..0f7a0f5a2 100644 --- a/packaging/common/cfengine-hub/preinstall.sh +++ b/packaging/common/cfengine-hub/preinstall.sh @@ -145,6 +145,16 @@ if is_upgrade; then fi fi +# CFE-4701: export the databases while the old mdb_dump is still installed; +# postinstall imports them. After the shutdown above so nothing is writing; a +# live dump would still be consistent (MVCC) but could miss the last writes. +# Not guarded by is_upgrade -- the Solaris pkg manager can never report one, and +# lmdb_migration_needed() detects the case by itself. +if lmdb_migration_needed; then + cf_console echo "LMDB format changed in this release, exporting databases before upgrading." + lmdb_dump_databases || cf_console echo "Warning: exporting the LMDB databases failed." +fi + filter_netstat_listen() { set +e diff --git a/packaging/common/cfengine-non-hub/postinstall.sh b/packaging/common/cfengine-non-hub/postinstall.sh index abfe61907..4ce4a97cd 100644 --- a/packaging/common/cfengine-non-hub/postinstall.sh +++ b/packaging/common/cfengine-non-hub/postinstall.sh @@ -9,6 +9,9 @@ if use_systemd; then fi fi +# CFE-4701: restore what preinstall exported, before anything uses the databases. +lmdb_load_databases || cf_console echo "Warning: importing the LMDB databases failed." + # # Generate a host key # diff --git a/packaging/common/cfengine-non-hub/preinstall.sh b/packaging/common/cfengine-non-hub/preinstall.sh index c6ce3570e..c6c32cebd 100644 --- a/packaging/common/cfengine-non-hub/preinstall.sh +++ b/packaging/common/cfengine-non-hub/preinstall.sh @@ -10,6 +10,14 @@ if is_upgrade; then cf_console platform_service cfengine3 stop fi +# CFE-4701: export the databases while the old mdb_dump is still installed; +# postinstall imports them. Not guarded by is_upgrade -- the Solaris pkg manager +# can never report one, and lmdb_migration_needed() detects the case by itself. +if lmdb_migration_needed; then + cf_console echo "LMDB format changed in this release, exporting databases before upgrading." + lmdb_dump_databases || cf_console echo "Warning: exporting the LMDB databases failed." +fi + case `os_type` in redhat) # diff --git a/packaging/common/produce-script b/packaging/common/produce-script index c7ac8d365..413305507 100755 --- a/packaging/common/produce-script +++ b/packaging/common/produce-script @@ -50,6 +50,12 @@ echo "BUILT_ON_OS=$OS" BUILT_ON_OS_VERSION="`expr "$OS_VERSION" : "\([0-9]*\)"`" echo "BUILT_ON_OS_VERSION=$BUILT_ON_OS_VERSION" +# Shipped LMDB version. The install scripts compare it against the installed one +# to decide whether the databases need a dump/reload, see +# lmdb_migration_needed() (CFE-4701). +LMDB_VERSION="`sed -e 's,/*$,,' -e 's,.*/LMDB_,,' "$SCRIPTDIR/../../deps-packaging/lmdb/source" 2>/dev/null`" +echo "LMDB_VERSION=$LMDB_VERSION" + case "$SCRIPT_TYPE" in *install) include_script "$TEMPLATEDIR/$PKG_TYPE-script-common-install.sh" diff --git a/packaging/common/script-templates/script-common.sh b/packaging/common/script-templates/script-common.sh index 4b7e5b9ae..edad0693a 100644 --- a/packaging/common/script-templates/script-common.sh +++ b/packaging/common/script-templates/script-common.sh @@ -180,3 +180,87 @@ on_files() { unset IFS } +# +# LMDB database migration (CFE-4701) +# +# LMDB 1.0 added a txnid to the page header, so it reads the meta page magic from +# where 0.9 keeps another field and rejects 0.9 databases as MDB_INVALID. +# CFEngine takes that for corruption and deletes them (ENT-9717). +# +# There is no in-place upgrade: dump with the old mdb_dump, load with the new +# mdb_load. The dump belongs in preinstall, while the old binary is still there; +# the new mdb_dump cannot read 0.9 either. +# +# A dump lives beside its database as .dump. Nothing else picks those up: +# CFEngine and cf-check both select databases by a .lmdb suffix. +# +# Non-fatal throughout -- a failed migration just leaves the old behaviour, which +# is no reason to abort an upgrade. Do not rely on `set -e`: these are called as +# `... || ...`, which suppresses errexit for the whole function body. +# +# The globs cover the state dir and the workdir, where pre-3.7 installations keep +# databases CFEngine still prefers (DB_PATHS_WORKDIR in dbm_api.c). An unmatched +# glob comes through literally, hence the `test -f`. + +lmdb_series() { + # "." of the version string on stdin. The on-disk format is + # stable within a series. + sed -n 's/^[^0-9]*\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -n 1 +} + +lmdb_migration_needed() { + # True when the installed mdb_dump reports a different series than the one + # this package ships. Anything undeterminable is false, keeping the old + # behaviour. + test -n "$LMDB_VERSION" || return 1 + test -x "$PREFIX/bin/mdb_dump" || return 1 + packaged=`echo "$LMDB_VERSION" | lmdb_series` + installed=`"$PREFIX/bin/mdb_dump" -V 2>/dev/null | lmdb_series` + test -n "$packaged" && test -n "$installed" || return 1 + test "$installed" != "$packaged" +} + +lmdb_dump_databases() { + # Export every database with the installed (old) mdb_dump. Preinstall only. + for db in "$PREFIX"/state/*.lmdb "$PREFIX"/*.lmdb; do + test -f "$db" || continue + rm -f "$db.dump" + # Dumps hold database contents; subshell keeps the umask from leaking. + if ( umask 077; "$PREFIX/bin/mdb_dump" -n -f "$db.dump" "$db" ); then + echo "exported '$db'" + else + # Corrupt or uninitialised: nothing to preserve, leave it to cf-check + # rather than restoring a bad dump. + rm -f "$db.dump" + cf_console echo "Warning: could not export '$db', it will be recreated empty." + fi + done + return 0 +} + +lmdb_load_databases() { + # Re-import what lmdb_dump_databases() exported, with the new mdb_load. + # Postinstall only, before any daemon starts. + for dump in "$PREFIX"/state/*.lmdb.dump "$PREFIX"/*.lmdb.dump; do + test -f "$dump" || continue + db=${dump%.dump} + # Overwriting rather than replacing keeps the original inode, so owner, + # mode and SELinux label survive. umask only applies if $db is missing. + if ( umask 077; "$PREFIX/bin/mdb_load" -n -f "$dump" "$db.new" ) && + ( umask 077; cat "$db.new" > "$db" ); then + echo "imported '$db'" + rm -f "$dump" + else + # Keep the data for recovery, but under a name the glob above will + # not pick up, so a later upgrade cannot restore it over a good DB. + mv "$dump" "$dump.failed" + cf_console echo "Warning: could not import '$db', it will be recreated empty." + cf_console echo "Exported data kept in '$dump.failed'" + fi + rm -f "$db.new" "$db.new-lock" + # Tidy up the old lock file. LMDB copes with a stale one, it just + # reinitialises it. + rm -f "$db-lock" + done + return 0 +} From 1bab3d3f1ca8a80a7c8c9600889253da2704d139 Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Thu, 6 Aug 2026 15:57:57 -0500 Subject: [PATCH 2/2] Preserve LMDB databases on Windows across an on-disk format change The MSI runs none of the shell install scripts, so it needs its own migration. InstallFiles overwrites mdb_dump.exe in place, so a custom action copies the old one aside after StopServices and a second one migrates after InstallFiles. Ticket: CFE-4701 Changelog: LMDB databases are now preserved when upgrading the Windows agent across an LMDB on-disk format change --- build-scripts/package-msi | 3 + deps-packaging/lmdb/README.md | 6 ++ packaging/cfengine-nova/cfengine-nova.wxs | 24 +++++++ packaging/cfengine-nova/lmdb-migrate.cmd | 66 +++++++++++++++++++ packaging/common/cfengine-hub/preinstall.sh | 7 +- .../common/cfengine-non-hub/preinstall.sh | 5 +- packaging/common/produce-script | 3 +- .../common/script-templates/script-common.sh | 48 ++++---------- 8 files changed, 115 insertions(+), 47 deletions(-) create mode 100644 packaging/cfengine-nova/lmdb-migrate.cmd diff --git a/build-scripts/package-msi b/build-scripts/package-msi index 57bda1aa5..e56ba63ae 100755 --- a/build-scripts/package-msi +++ b/build-scripts/package-msi @@ -106,6 +106,9 @@ pre() { # Copy WiX source file for MSI generation cp "$BASEDIR"/buildscripts/packaging/cfengine-nova/cfengine-nova.wxs "$P" + # LMDB database migration, driven by custom actions in the .wxs (CFE-4701) + install -m 755 "$BASEDIR"/buildscripts/packaging/cfengine-nova/lmdb-migrate.cmd "$P"/bin/ + # Handle OpenSSL library naming differences between architectures # OpenSSL libs have different names on x32 and x64 platforms: # on 32-bit platforms: libcrypto_1_1.dll and libssl_1_1.dll diff --git a/deps-packaging/lmdb/README.md b/deps-packaging/lmdb/README.md index 0192e58b0..c32773dc4 100644 --- a/deps-packaging/lmdb/README.md +++ b/deps-packaging/lmdb/README.md @@ -13,6 +13,12 @@ patch bump is safe and a series bump is not. Series bumps are handled by version in `source` below via `LMDB_VERSION`. A format change *within* a series would need `lmdb_migration_needed()` made more specific. +Windows is handled separately, since the MSI runs none of those scripts: +`packaging/cfengine-nova/lmdb-migrate.cmd`, driven by the `LmdbSaveDumper` and +`LmdbMigrate` custom actions in `cfengine-nova.wxs`. It needs no version +threshold, because it probes each database with the new `mdb_dump` instead of +comparing versions. + ## Upgrading / patching From the directory above buildscripts: diff --git a/packaging/cfengine-nova/cfengine-nova.wxs b/packaging/cfengine-nova/cfengine-nova.wxs index 19129f7b5..375776622 100644 --- a/packaging/cfengine-nova/cfengine-nova.wxs +++ b/packaging/cfengine-nova/cfengine-nova.wxs @@ -45,6 +45,19 @@ generates the key pair at bootstrap if missing). --> + + + + + + + + + @@ -265,6 +282,13 @@ + + + OLDERVERSIONBEINGUPGRADED + OLDERVERSIONBEINGUPGRADED + OLDERVERSIONBEINGUPGRADED + NOT Installed diff --git a/packaging/cfengine-nova/lmdb-migrate.cmd b/packaging/cfengine-nova/lmdb-migrate.cmd new file mode 100644 index 000000000..3e14cb269 --- /dev/null +++ b/packaging/cfengine-nova/lmdb-migrate.cmd @@ -0,0 +1,66 @@ +@echo off +rem CFE-4701: dump each LMDB database with the old mdb_dump (saved aside as +rem mdb_dump-old.exe by the LmdbSaveDumper custom action) and load it with the +rem new mdb_load. Run from LmdbMigrate, after InstallFiles. Probes each database +rem rather than comparing versions -- easier than version parsing in batch. +rem Best-effort: on failure CFEngine just recreates the database. + +setlocal +set "BIN=%~dp0" +set "WORK=%BIN%.." +set "OLDDUMP=%BIN%mdb_dump-old.exe" +set "NEWDUMP=%BIN%mdb_dump.exe" +set "NEWLOAD=%BIN%mdb_load.exe" +set "LOG=%WORK%\lmdb-migrate.log" + +rem No saved dumper means this is not an upgrade across a format change. +if not exist "%OLDDUMP%" exit /b 0 +if not exist "%NEWDUMP%" exit /b 0 +if not exist "%NEWLOAD%" exit /b 0 + +echo [%DATE% %TIME%] lmdb-migrate: starting>>"%LOG%" + +rem State dir, plus the workdir for pre-3.7 installs. +for %%D in ("%WORK%\state\*.lmdb" "%WORK%\*.lmdb") do call :migrate_one "%%~fD" + +del /f /q "%OLDDUMP%" >nul 2>&1 +echo [%DATE% %TIME%] lmdb-migrate: done>>"%LOG%" +endlocal +exit /b 0 + +:migrate_one +set "DB=%~1" + +rem Readable by the new library already? Then leave it alone. +"%NEWDUMP%" -n "%DB%" >nul 2>&1 +if not errorlevel 1 exit /b 0 + +"%OLDDUMP%" -n -f "%DB%.dump" "%DB%" >nul 2>&1 +if errorlevel 1 ( + rem Corrupt or uninitialised: leave it to cf-check. + del /f /q "%DB%.dump" >nul 2>&1 + echo could not export "%DB%", it will be recreated empty>>"%LOG%" + exit /b 0 +) + +"%NEWLOAD%" -n -f "%DB%.dump" "%DB%.new" >nul 2>&1 +if errorlevel 1 ( + del /f /q "%DB%.new" "%DB%.new-lock" >nul 2>&1 + echo could not import "%DB%", it will be recreated empty>>"%LOG%" + echo exported data kept in "%DB%.dump">>"%LOG%" + exit /b 0 +) + +rem Overwrite rather than replace, so the original file's ACL survives. +copy /y "%DB%.new" "%DB%" >nul 2>&1 +if errorlevel 1 ( + del /f /q "%DB%.new" "%DB%.new-lock" >nul 2>&1 + echo could not write "%DB%", it will be recreated empty>>"%LOG%" + echo exported data kept in "%DB%.dump">>"%LOG%" + exit /b 0 +) + +rem The lock file carries its own format version. +del /f /q "%DB%.new" "%DB%.new-lock" "%DB%-lock" "%DB%.dump" >nul 2>&1 +echo imported "%DB%">>"%LOG%" +exit /b 0 diff --git a/packaging/common/cfengine-hub/preinstall.sh b/packaging/common/cfengine-hub/preinstall.sh index 0f7a0f5a2..1a943e901 100644 --- a/packaging/common/cfengine-hub/preinstall.sh +++ b/packaging/common/cfengine-hub/preinstall.sh @@ -145,11 +145,8 @@ if is_upgrade; then fi fi -# CFE-4701: export the databases while the old mdb_dump is still installed; -# postinstall imports them. After the shutdown above so nothing is writing; a -# live dump would still be consistent (MVCC) but could miss the last writes. -# Not guarded by is_upgrade -- the Solaris pkg manager can never report one, and -# lmdb_migration_needed() detects the case by itself. +# CFE-4701: export while the old mdb_dump is still installed; postinstall +# imports. Not guarded by is_upgrade -- Solaris never reports one. if lmdb_migration_needed; then cf_console echo "LMDB format changed in this release, exporting databases before upgrading." lmdb_dump_databases || cf_console echo "Warning: exporting the LMDB databases failed." diff --git a/packaging/common/cfengine-non-hub/preinstall.sh b/packaging/common/cfengine-non-hub/preinstall.sh index c6c32cebd..18ed1ea8d 100644 --- a/packaging/common/cfengine-non-hub/preinstall.sh +++ b/packaging/common/cfengine-non-hub/preinstall.sh @@ -10,9 +10,8 @@ if is_upgrade; then cf_console platform_service cfengine3 stop fi -# CFE-4701: export the databases while the old mdb_dump is still installed; -# postinstall imports them. Not guarded by is_upgrade -- the Solaris pkg manager -# can never report one, and lmdb_migration_needed() detects the case by itself. +# CFE-4701: export while the old mdb_dump is still installed; postinstall +# imports. Not guarded by is_upgrade -- Solaris never reports one. if lmdb_migration_needed; then cf_console echo "LMDB format changed in this release, exporting databases before upgrading." lmdb_dump_databases || cf_console echo "Warning: exporting the LMDB databases failed." diff --git a/packaging/common/produce-script b/packaging/common/produce-script index 413305507..00e5b4e7f 100755 --- a/packaging/common/produce-script +++ b/packaging/common/produce-script @@ -50,8 +50,7 @@ echo "BUILT_ON_OS=$OS" BUILT_ON_OS_VERSION="`expr "$OS_VERSION" : "\([0-9]*\)"`" echo "BUILT_ON_OS_VERSION=$BUILT_ON_OS_VERSION" -# Shipped LMDB version. The install scripts compare it against the installed one -# to decide whether the databases need a dump/reload, see +# Shipped LMDB version, compared against the installed one by # lmdb_migration_needed() (CFE-4701). LMDB_VERSION="`sed -e 's,/*$,,' -e 's,.*/LMDB_,,' "$SCRIPTDIR/../../deps-packaging/lmdb/source" 2>/dev/null`" echo "LMDB_VERSION=$LMDB_VERSION" diff --git a/packaging/common/script-templates/script-common.sh b/packaging/common/script-templates/script-common.sh index edad0693a..fcbedda6d 100644 --- a/packaging/common/script-templates/script-common.sh +++ b/packaging/common/script-templates/script-common.sh @@ -180,38 +180,18 @@ on_files() { unset IFS } -# -# LMDB database migration (CFE-4701) -# -# LMDB 1.0 added a txnid to the page header, so it reads the meta page magic from -# where 0.9 keeps another field and rejects 0.9 databases as MDB_INVALID. -# CFEngine takes that for corruption and deletes them (ENT-9717). -# -# There is no in-place upgrade: dump with the old mdb_dump, load with the new -# mdb_load. The dump belongs in preinstall, while the old binary is still there; -# the new mdb_dump cannot read 0.9 either. -# -# A dump lives beside its database as .dump. Nothing else picks those up: -# CFEngine and cf-check both select databases by a .lmdb suffix. -# -# Non-fatal throughout -- a failed migration just leaves the old behaviour, which -# is no reason to abort an upgrade. Do not rely on `set -e`: these are called as -# `... || ...`, which suppresses errexit for the whole function body. -# -# The globs cover the state dir and the workdir, where pre-3.7 installations keep -# databases CFEngine still prefers (DB_PATHS_WORKDIR in dbm_api.c). An unmatched -# glob comes through literally, hence the `test -f`. +# LMDB migration (CFE-4701). LMDB 1.0 cannot read 0.9 databases, so dump in +# preinstall with the old mdb_dump and load in postinstall with the new mdb_load. +# Dumps sit beside the database as .dump. Non-fatal throughout: callers use +# `... || ...`, which disables errexit inside these functions. lmdb_series() { - # "." of the version string on stdin. The on-disk format is - # stable within a series. + # "." from stdin; the format is stable within a series. sed -n 's/^[^0-9]*\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -n 1 } lmdb_migration_needed() { - # True when the installed mdb_dump reports a different series than the one - # this package ships. Anything undeterminable is false, keeping the old - # behaviour. + # False when undeterminable, keeping the old behaviour. test -n "$LMDB_VERSION" || return 1 test -x "$PREFIX/bin/mdb_dump" || return 1 packaged=`echo "$LMDB_VERSION" | lmdb_series` @@ -221,16 +201,15 @@ lmdb_migration_needed() { } lmdb_dump_databases() { - # Export every database with the installed (old) mdb_dump. Preinstall only. + # Preinstall only, while the old mdb_dump is still installed. The workdir + # glob covers pre-3.7 installs. for db in "$PREFIX"/state/*.lmdb "$PREFIX"/*.lmdb; do test -f "$db" || continue rm -f "$db.dump" - # Dumps hold database contents; subshell keeps the umask from leaking. if ( umask 077; "$PREFIX/bin/mdb_dump" -n -f "$db.dump" "$db" ); then echo "exported '$db'" else - # Corrupt or uninitialised: nothing to preserve, leave it to cf-check - # rather than restoring a bad dump. + # Corrupt or uninitialised: leave it to cf-check. rm -f "$db.dump" cf_console echo "Warning: could not export '$db', it will be recreated empty." fi @@ -239,27 +218,22 @@ lmdb_dump_databases() { } lmdb_load_databases() { - # Re-import what lmdb_dump_databases() exported, with the new mdb_load. # Postinstall only, before any daemon starts. for dump in "$PREFIX"/state/*.lmdb.dump "$PREFIX"/*.lmdb.dump; do test -f "$dump" || continue db=${dump%.dump} - # Overwriting rather than replacing keeps the original inode, so owner, - # mode and SELinux label survive. umask only applies if $db is missing. + # Overwrite rather than replace, so owner, mode and SELinux label survive. if ( umask 077; "$PREFIX/bin/mdb_load" -n -f "$dump" "$db.new" ) && ( umask 077; cat "$db.new" > "$db" ); then echo "imported '$db'" rm -f "$dump" else - # Keep the data for recovery, but under a name the glob above will - # not pick up, so a later upgrade cannot restore it over a good DB. + # .failed so a later upgrade cannot restore it over a good database. mv "$dump" "$dump.failed" cf_console echo "Warning: could not import '$db', it will be recreated empty." cf_console echo "Exported data kept in '$dump.failed'" fi rm -f "$db.new" "$db.new-lock" - # Tidy up the old lock file. LMDB copes with a stale one, it just - # reinitialises it. rm -f "$db-lock" done return 0