From 5eadb2ea34dcc93a94147e34980a02a5f33fd465 Mon Sep 17 00:00:00 2001 From: prsmith777 Date: Wed, 21 Oct 2026 18:10:07 -0600 Subject: [PATCH 01/12] Add verified Docker source exports for Windows migrations --- .github/workflows/ci.yml | 32 ++++ README.md | 4 +- alpr-community | 4 + docs/COMMUNITY_PRODUCT_ROADMAP.md | 14 +- docs/DOCKER_TO_WINDOWS_MIGRATION.md | 123 +++++++++++++++ docs/MIGRATION_GUIDE.md | 6 +- docs/WINDOWS_INSTALL.md | 13 ++ docs/WINDOWS_NATIVE.md | 6 + lib/help-manual.mjs | 10 +- scripts/build-windows-native.mjs | 4 +- scripts/docker-migration-client.mjs | 56 +++++++ scripts/docker-migration-export.mjs | 171 +++++++++++++++++++++ scripts/docker-migration-interactive.mjs | 35 +++++ scripts/postgres-major-migration.mjs | 2 + scripts/test-docker-migration-export.mjs | 84 ++++++++++ scripts/test-windows-migration-runtime.mjs | 9 ++ scripts/windows/CommunitySetup.iss | 49 +++++- scripts/windows/ExportDockerMigration.ps1 | 92 +++++++++++ scripts/windows/ExportMigration.ps1 | 2 +- test/docker-migration-export.test.mjs | 74 +++++++++ test/help-manual.test.mjs | 4 +- 21 files changed, 780 insertions(+), 14 deletions(-) create mode 100644 docs/DOCKER_TO_WINDOWS_MIGRATION.md create mode 100644 scripts/docker-migration-client.mjs create mode 100644 scripts/docker-migration-export.mjs create mode 100644 scripts/docker-migration-interactive.mjs create mode 100644 scripts/test-docker-migration-export.mjs create mode 100644 scripts/windows/ExportDockerMigration.ps1 create mode 100644 test/docker-migration-export.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7803374..6fceaac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -347,7 +347,31 @@ jobs: - name: Verify runtime-only image contents run: node scripts/verify-runtime-image.mjs alpr-ci:${{ github.sha }} + docker-migration-export: + name: Docker source export and portable Windows backup + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: 24 + - name: Install locked export dependencies + run: yarn install --frozen-lockfile --ignore-scripts + - name: Export isolated Original PostgreSQL 13 and 17 sources + env: + ALPR_DOCKER_TEST_OUTPUT: ${{ runner.temp }}/docker-migration-bundle + run: node scripts/test-docker-migration-export.mjs + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: synthetic-docker-migration-bundle + path: ${{ runner.temp }}/docker-migration-bundle + retention-days: 7 + windows-native: + needs: docker-migration-export name: Native Windows preview runs-on: windows-2022 timeout-minutes: 30 @@ -360,6 +384,14 @@ jobs: uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 + - name: Download synthetic Linux Docker export for real Windows restore + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: synthetic-docker-migration-bundle + path: ${{ runner.temp }}/docker-migration-bundle + - name: Set portable backup fixture path + shell: pwsh + run: Add-Content -LiteralPath $env:GITHUB_ENV -Value "ALPR_DOCKER_TEST_BUNDLE=$env:RUNNER_TEMP/docker-migration-bundle" - name: Use a canonical disposable fixture directory shell: pwsh run: | diff --git a/README.md b/README.md index a1fc03d..22f5fc0 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,9 @@ technical details and recovery. For Docker-to-native migration on the same Windows computer, keep Docker on port 3000 and choose an unused application port such as 3001 in Windows Setup. Native Community uses a separate PostgreSQL 17 database, normally on loopback -port 5433. The [Windows migration and port guide](docs/WINDOWS_INSTALL.md#keep-docker-alpr-while-verifying-native-windows) +port 5433. First [export the original Docker database](docs/DOCKER_TO_WINDOWS_MIGRATION.md) +on its Windows or Linux source host; the native Windows backup shortcut does +not export Docker. The [Windows migration and port guide](docs/WINDOWS_INSTALL.md#keep-docker-alpr-while-verifying-native-windows) explains backup, verification and changing either the application or PostgreSQL port later under Settings → General. ### Linux diff --git a/alpr-community b/alpr-community index 1b3dba7..551bcc2 100755 --- a/alpr-community +++ b/alpr-community @@ -43,6 +43,10 @@ fi if [[ "${1:-}" == "migrate" ]]; then shift + if [[ "${1:-}" == "export-docker" ]]; then + shift + exec "${node_binary}" "${installation_dir}/scripts/docker-migration-interactive.mjs" "$@" + fi if [[ "${1:-}" == "wizard" ]]; then shift exec "${node_binary}" "${installation_dir}/scripts/community-migration-wizard.mjs" "$@" diff --git a/docs/COMMUNITY_PRODUCT_ROADMAP.md b/docs/COMMUNITY_PRODUCT_ROADMAP.md index b086c38..8fc3af2 100644 --- a/docs/COMMUNITY_PRODUCT_ROADMAP.md +++ b/docs/COMMUNITY_PRODUCT_ROADMAP.md @@ -1,6 +1,18 @@ # Community product roadmap -Last reviewed: October 7, 2026 +Last reviewed: October 21, 2026 + +## Docker-to-Windows export implementation + +The next release adds source-selecting export for Original/Community Docker +installations on Windows and Linux. Windows Setup exposes export before native +installation; Linux uses ./alpr-community migrate export-docker. Both produce the +same verified logical backup, with source profile/record-count review, image and +authentication preservation, and restoration of the selected app's original +running state. Native imports continue to require an empty target and do not +merge histories. See [migration procedure](DOCKER_TO_WINDOWS_MIGRATION.md). +Acceptance and release status must be checked in the associated pull request; +this section does not claim the change is already published. ## Native Windows is available diff --git a/docs/DOCKER_TO_WINDOWS_MIGRATION.md b/docs/DOCKER_TO_WINDOWS_MIGRATION.md new file mode 100644 index 0000000..d749aed --- /dev/null +++ b/docs/DOCKER_TO_WINDOWS_MIGRATION.md @@ -0,0 +1,123 @@ +# Move Docker ALPR data to native Windows + +Use this procedure for either **Docker Desktop on Windows** or **Docker on +Linux**, with an Original ALPR v0.1.9-compatible database or Community v0.1.20+ +on PostgreSQL 13 or 17. It creates the same verified folder that Windows Setup +imports. Do not update, replace or delete the old Docker database to export it. + +These controls are part of the Docker migration exporter change; use a release +that includes them. Older releases, including v0.1.55, do not include this +graphical Docker exporter. + +## Choose the correct backup tool + +| Source | Export tool | +| --- | --- | +| Original or Community in Docker Desktop on Windows | Setup → Move an existing ALPR database and images → **Back up original Docker ALPR…** | +| Original or Community in Docker on Linux | On the Linux source host, from a prepared current Community checkout: `./alpr-community migrate export-docker` | +| Native Windows Community | Windows Start → **ALPR Migration Backup** | +| Linux PostgreSQL outside Docker | The advanced [Linux portable backup procedure](WINDOWS_NATIVE.md#create-the-backup-on-linux), using its actual endpoint and image paths | + +The native Windows backup shortcut exports the native database only. Installing +an empty Community database and using that shortcut does **not** export the old +Docker database. + +## Docker Desktop on Windows + +1. Run Docker Desktop as the Windows account that owns the original installation. + Keep it in Linux-container mode. Keep the original application, PostgreSQL + container and all their volumes. +2. Open the new Windows Setup. Select **Move an existing ALPR database and + images**, then **Back up original Docker ALPR…**. Export is available before + installing Community; you do not need a temporary empty installation. +3. Select the old Docker application and its PostgreSQL container by name and + image. The tool checks that the selected database matches the application's + configured Docker network address. It never selects the native Windows service. +4. Select **Review source**. Check the source application type, PostgreSQL version + and **plate-read count**. If the count is zero and you expected historical + records, cancel and select the correct source. Unknown or incomplete schemas + stop the export. +5. Pause other jobs or applications that write to this database or image storage. + Confirm the displayed source and choose a private local backup location. + The tool stops only the selected Docker ALPR application, makes and verifies + its logical dump, images, settings and authentication, then restarts it if it + was running. An already stopped application stays stopped. +6. Wait for **Docker backup complete**. The installer selects the resulting + folder automatically. Keep the entire folder, including `migration-backup.json`. + A failure preserves source data and any partial export; keep the diagnostic + folder. A partial export is not a usable migration backup. +7. Pause ingestion on the old source **again** before importing. The backup tool + restarts the original app; the confirmation box in Setup is deliberately reset. + Continue with the import procedure below. + +The tool uses PostgreSQL 17 client containers and may download the matching +official image. It connects within the selected database container's network +namespace using read-only database sessions. It does not publish a database port +or copy the physical PostgreSQL cluster. Docker's original ports and volumes +remain unchanged. Keep Docker running until export completes. + +After installation, **ALPR Docker Migration Backup** in Windows Start opens the +same Docker exporter. The older **ALPR Migration Backup** shortcut clearly +identifies itself as a native Windows backup. + +## Docker on Linux → Windows + +1. On the Linux computer that owns the Docker containers, prepare a separate + current Community checkout with its locked dependencies and Node.js 24 + (or use an existing current Community checkout). This prepares the exporter; + it does not replace the old running application. Use the normal account with + access to that Docker engine. +2. From that checkout run: + + ```bash + ./alpr-community migrate export-docker + ``` + +3. Select the original application and database from the numbered lists. Enter + an absolute path to a private local backup directory. Review the displayed + source type and plate-read count. +4. Pause every other database/image writer, then type the confirmation shown by + the exporter. The selected source application is briefly stopped and returned + to its original running/stopped state. Wait for the verified backup path. +5. Copy the **entire completed backup folder** from Linux to Windows using your + normal protected file transfer. Include hidden files and all manifests. + Never copy `/var/lib/postgresql/data` into the Windows database directory. +6. Pause old-source ingestion again before import. In Windows Setup select + **Move an existing ALPR database and images**, browse to the copied folder + containing `migration-backup.json`, and continue below. + +## Import and verify + +Use a **fresh, empty native Windows target**. Keep Docker's application port +unchanged; if it uses 3000 on the same Windows computer, choose 3001 for native +Community. Normally keep the separate native database at 5433. + +Setup verifies checksums, source schema, exact pre-upgrade table counts, migrated +records and referenced image files before starting the application. Review +integration destinations and then verify sign-in, historical records, tags, +representative images, settings and one controlled new read. Only then change +the intended Blue Iris destination to the new application address. A migrated +authentication file preserves the ingestion API key; otherwise obtain the new +key in Settings → Security. The setup administrator uses the password selected +during Setup. Windows creates separate protected PostgreSQL credentials. + +Keep the original installation and completed backup until the move is accepted. +The two databases are independent after export; later records are not synchronized. + +## If Community already contains new records + +**Do not delete or reinstall it to make room for an import.** First make a +separate native Community backup and keep the original Docker export. The +importer refuses a populated target and does not merge old and new histories. +Use another clean Windows computer/VM to verify the old history, or plan a +replacement with the maintainer while retaining both datasets and backups. + +## If source review fails + +Keep the exact displayed error and container names/images. Do not share Docker +inspection output containing environment variables, passwords, API keys, images +or real plate data publicly. The exporter supports the documented ALPR mounts +`/app/storage`, `/app/config` and `/app/auth`, and a directly associated Docker +PostgreSQL container. External databases, secret-file credentials, renamed +application data paths and unsupported schemas require the advanced procedure +or maintainer help; the exporter does not guess or rewrite them. diff --git a/docs/MIGRATION_GUIDE.md b/docs/MIGRATION_GUIDE.md index d2a3f46..e286291 100644 --- a/docs/MIGRATION_GUIDE.md +++ b/docs/MIGRATION_GUIDE.md @@ -5,7 +5,11 @@ For a native Windows destination, start with the installer's **Move an existing ALPR database and images** option and the verified portable backup described in [Linux-to-Windows migration](WINDOWS_NATIVE.md#cross-platform-import). -The workflow below creates a separate Linux Docker target. +For Docker sources on either Windows or Linux, follow the +[Docker-to-Windows export procedure](DOCKER_TO_WINDOWS_MIGRATION.md). It identifies +the original container/database and creates the import folder; the native Windows +backup shortcut does not export Docker. The workflow below creates a separate +Linux Docker target. The Community migration wizard moves a supported existing ALPR database and image library into a separate PostgreSQL 17 Community target. It creates the diff --git a/docs/WINDOWS_INSTALL.md b/docs/WINDOWS_INSTALL.md index 7518b16..0e68216 100644 --- a/docs/WINDOWS_INSTALL.md +++ b/docs/WINDOWS_INSTALL.md @@ -68,6 +68,19 @@ The current installer is unsigned, so Windows may identify an unknown publisher. Use the official release and its checksum. If antivirus blocks a file, retain the product's warning and the exact filename for support; keep antivirus enabled. +## Export an existing Docker database first + +For Docker Desktop on Windows, choose **Move an existing ALPR database and images**, +then **Back up original Docker ALPR…** on the migration page. Review the selected +Docker source and record count before exporting. You do not need to install an +empty Community database first. The ordinary **ALPR Migration Backup** shortcut +exports native Windows Community only. + +For Docker on Linux, run **./alpr-community migrate export-docker** from a prepared +current Community checkout on the source host, then copy the entire verified +folder to Windows. See [Docker-to-Windows migration](DOCKER_TO_WINDOWS_MIGRATION.md) +for both scenarios and handling a native installation that already contains records. + ## Keep Docker ALPR while verifying native Windows On the same Windows computer, keep Docker's web/API mapping at 3000 and choose diff --git a/docs/WINDOWS_NATIVE.md b/docs/WINDOWS_NATIVE.md index a40da4d..54a0863 100644 --- a/docs/WINDOWS_NATIVE.md +++ b/docs/WINDOWS_NATIVE.md @@ -420,6 +420,12 @@ Windows-invalid filenames, changed backups, and existing destination data are refused. Copy the entire completed folder to Windows, including the manifests. Do not copy PostgreSQL's physical data directory between operating systems. +For Docker sources on Windows or Linux, prefer the +[Docker source exporter](DOCKER_TO_WINDOWS_MIGRATION.md). It displays the source +profile and record count and produces this verified bundle without requiring a +native Windows installation first. The advanced endpoint procedure below remains +available for non-Docker Linux sources. + ### Create the backup on Linux This step is performed by the source administrator. Keep the original database diff --git a/lib/help-manual.mjs b/lib/help-manual.mjs index 4d0897c..01190ff 100644 --- a/lib/help-manual.mjs +++ b/lib/help-manual.mjs @@ -12,7 +12,7 @@ export const HELP_MANUAL = Object.freeze({ title: "ALPR Database Community User Guide", shortTitle: "User Guide", description: "A practical guide to using, configuring, and safely administering the portable Community application.", - manualVersion: "3.23", + manualVersion: "3.24", updatedAt: "October 7, 2026", coverageBaseline: "Published clean-history Community release with portable configuration and no private deployment controls.", filename: "ALPR-Database-Community-User-Guide.pdf", @@ -91,9 +91,9 @@ export const HELP_MANUAL = Object.freeze({ }, { "type": "steps", - "title": "Create a portable Windows backup", + "title": "Create a portable native Windows backup", "items": [ - "Open ALPR Migration Backup from Windows Start and approve the administrator prompt. Choose a private backup location and confirm the brief ingestion pause.", + "For native Windows Community only, open ALPR Migration Backup from Windows Start and approve the administrator prompt. This shortcut does not export a Docker installation. Choose a private backup location and confirm the brief ingestion pause.", "Wait for the verified backup confirmation. The tool pauses the owned ALPR service, verifies the database and file bundle, and restarts the service. Copy the entire backup folder, including migration-backup.json and its manifests, to a separate protected destination.", "Keep periodic separate backups for disaster recovery. Normal software updates already create their own verified recovery backup and do not require a manual migration export each time." ] @@ -102,7 +102,9 @@ export const HELP_MANUAL = Object.freeze({ "type": "steps", "title": "Move Linux or Windows data to a new Windows computer", "items": [ - "For Linux, have the source administrator create a verified portable migration folder using the GitHub Native Windows guide. A database dump alone is incomplete; include the image library, settings, authentication and manifests. Never copy PostgreSQL physical database files between operating systems.", + "For Docker Desktop on Windows, run Setup, choose Move an existing ALPR database and images, and select Back up original Docker ALPR. Select the original Docker app and database, review their source version and plate-read count, and confirm the export. This works before installing Community. The native Windows backup shortcut is a different tool.", + "For Docker on Linux, run ./alpr-community migrate export-docker from a prepared current Community checkout on the source host. Review the original source and counts, then copy the entire completed backup folder to Windows. For non-Docker Linux databases use the advanced portable backup procedure. A SQL dump alone is incomplete.", + "Pause other database/image writers before exporting. The exporter briefly stops only the selected Docker app and returns it to its original running/stopped state. Pause source ingestion again before import. If native Community already contains records, preserve its separate backup and plan a clean target; import never merges histories.", "Native Windows creates its own PostgreSQL 17 cluster under C:\\ProgramData\\ALPR Community\\management\\postgres, normally at 127.0.0.1:5433. A logical backup import does not modify the source Docker PostgreSQL 13 database or its port mappings. Databases are independent after the import; new records are not synchronized.", "Copy the completed folder to the new computer. Pause source ingestion, run Setup, choose Move an existing ALPR database and images, and select the folder containing migration-backup.json. This requires an empty destination.", "Choose the destination setup administrator password. Named accounts and ingestion keys are retained; the compatibility administrator uses the newly chosen password and old browser sessions are invalidated.", diff --git a/scripts/build-windows-native.mjs b/scripts/build-windows-native.mjs index 0729bf4..984f8dd 100644 --- a/scripts/build-windows-native.mjs +++ b/scripts/build-windows-native.mjs @@ -98,14 +98,14 @@ async function main() { await writeFile(path.join(destination, "runtime", name), await license.text()); } await mkdir(path.join(destination, "host")); - for (const file of ["windows-database-port.mjs","windows-application-port.mjs","windows-update-host.mjs","windows-enable-updates.mjs","windows-update-service.mjs","windows-update-worker.mjs","windows-update-release.mjs","windows-native-package.mjs", "windows-service.mjs", "windows-service-startup.mjs", "windows-maintenance.mjs", "windows-code-repair.mjs", "windows-deployment.mjs", "native-reid-upgrade-policy.mjs", "windows-migration.mjs", "community-migration-bundle.mjs", "postgres-major-migration.mjs"]) { + for (const file of ["docker-migration-client.mjs", "docker-migration-export.mjs", "windows-database-port.mjs","windows-application-port.mjs","windows-update-host.mjs","windows-enable-updates.mjs","windows-update-service.mjs","windows-update-worker.mjs","windows-update-release.mjs","windows-native-package.mjs", "windows-service.mjs", "windows-service-startup.mjs", "windows-maintenance.mjs", "windows-code-repair.mjs", "windows-deployment.mjs", "native-reid-upgrade-policy.mjs", "windows-migration.mjs", "community-migration-bundle.mjs", "postgres-major-migration.mjs"]) { await cp(path.join(root, "scripts", file), path.join(destination, "host", file)); } for (const file of ["schema.sql", "migrations.sql", "LICENSE"]) await cp(path.join(root, file), path.join(destination, file)); await cp(path.join(root, "scripts", "windows", "Install.ps1"), path.join(destination, "Install.ps1")); await cp(path.join(root, "docs", "WINDOWS_NATIVE.md"), path.join(destination, "README-WINDOWS.md")); await cp(path.join(root, "scripts", "windows", "Service-Control.ps1"), path.join(destination, "host", "Service-Control.ps1")); - for (const file of ["Database-Port.ps1","Application-Port.ps1","Pause-Updates.ps1","Enable-Updates.ps1","Update-Service.ps1","Expand-Update.ps1","Network.ps1","Network-Helpers.ps1","Setup-Helpers.ps1","ExportMigration.ps1"]) { + for (const file of ["Database-Port.ps1","Application-Port.ps1","Pause-Updates.ps1","Enable-Updates.ps1","Update-Service.ps1","Expand-Update.ps1","Network.ps1","Network-Helpers.ps1","Setup-Helpers.ps1","ExportMigration.ps1","ExportDockerMigration.ps1"]) { await cp(path.join(root,"scripts/windows",file),path.join(destination,"host",file)); } await mkdir(path.join(destination,"lib")); diff --git a/scripts/docker-migration-client.mjs b/scripts/docker-migration-client.mjs new file mode 100644 index 0000000..1d76271 --- /dev/null +++ b/scripts/docker-migration-client.mjs @@ -0,0 +1,56 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; +import { existsSync } from "node:fs"; +import { randomUUID } from "node:crypto"; + +export const POSTGRES_CLIENT_IMAGE = "postgres:17.10"; +export function dockerCommand() { + const desktop = path.join(process.env.ProgramFiles || "C:\\Program Files", "Docker", "Docker", "resources", "bin", "docker.exe"); + return process.platform === "win32" && existsSync(desktop) ? desktop : "docker"; +} +export function docker(args, { environment = process.env, timeout = 120_000 } = {}) { + return new Promise((resolve, reject) => { + const child = spawn(dockerCommand(), args, { env: environment, shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = "", stderr = ""; + const timer = setTimeout(() => { child.kill(); reject(new Error("Docker operation timed out. Keep the source and backup files.")); }, timeout); + child.stdout.on("data", chunk => { stdout += chunk; }); + child.stderr.on("data", chunk => { stderr += chunk; }); + child.on("error", () => { clearTimeout(timer); reject(new Error("Docker is unavailable. Start Docker Desktop in Linux-container mode and run the tool as the account that uses Docker.")); }); + child.on("close", code => { + clearTimeout(timer); + if (code === 0) resolve({ stdout, stderr }); + else reject(new Error("Docker operation failed (" + String(args[0]) + "). Check Docker is running and the selected source is available.")); + }); + }); +} +// Used only by the export path. No source volume or Docker socket is mounted. +export function dockerPostgresArguments(program, args, env) { + const tool = path.basename(program).replace(/\.exe$/i, ""); + const source = env.ALPR_MIGRATION_DOCKER_SOURCE; + const scratch = env.ALPR_MIGRATION_DOCKER_SCRATCH; + if (!/^[a-f0-9]{64}$/.test(source || "") || !path.isAbsolute(scratch || "") || /[,\r\n]/.test(scratch)) throw new Error("Invalid Docker export context."); + if (!["psql", "pg_dump", "pg_restore"].includes(tool)) throw new Error("Unsupported Docker export tool."); + const mapped = [...args]; + const file = mapped.indexOf("--file"); + if (file >= 0) { + if (tool !== "pg_dump" || mapped[file + 1] !== path.join(scratch, "postgres.dump")) throw new Error("Docker export refuses an unexpected file destination."); + mapped[file + 1] = "/export/postgres.dump"; + } + if (tool === "pg_restore" && (mapped.length !== 1 || mapped[0] !== "--version")) throw new Error("Docker export cannot restore a database."); + if (tool === "psql" && !mapped.includes("--version") && !mapped.includes("--command")) throw new Error("Docker export accepts read-only source queries only."); + return ["run", "--rm", "--name", "alpr-export-client-" + randomUUID(), + "--label", "org.alpr.migration-client=true", "--network", "container:" + source, + "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", + "--mount", "type=bind,src=" + scratch + ",dst=/export", + "--env", "PGPASSWORD", "--env", "PGSSLMODE", "--env", "PGOPTIONS=-c default_transaction_read_only=on -c statement_timeout=120000", + "--env", "PGCONNECT_TIMEOUT=10", "--entrypoint", tool, POSTGRES_CLIENT_IMAGE, ...mapped]; +} +export async function dockerPostgres(program, args, environment) { + const command = dockerPostgresArguments(program, args, environment); + try { + return await docker(command, { environment, timeout: 30 * 60_000 }); + } finally { + // The generated name is owned by this invocation, including timeout cases. + await docker(["rm", "--force", command[command.indexOf("--name") + 1]]).catch(() => {}); + } +} diff --git a/scripts/docker-migration-export.mjs b/scripts/docker-migration-export.mjs new file mode 100644 index 0000000..be5279d --- /dev/null +++ b/scripts/docker-migration-export.mjs @@ -0,0 +1,171 @@ +import { mkdir, mkdtemp, readFile, writeFile, rm, lstat } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; +import { randomUUID } from "node:crypto"; +import { docker, POSTGRES_CLIENT_IMAGE } from "./docker-migration-client.mjs"; +import { postgresMajorMigrationInternals as pg, runPostgresMigrationCommand } from "./postgres-major-migration.mjs"; +import { createMigrationBundle, verifyMigrationBundle } from "./community-migration-bundle.mjs"; +import { fileInventory, assertRealDirectory } from "./windows-deployment.mjs"; + +const require = createRequire(new URL("../app/package.json", import.meta.url)); +function envOf(record) { + return Object.fromEntries((record.Config?.Env || []).map(value => { const at = value.indexOf("="); return [value.slice(0, at), value.slice(at + 1)]; })); +} +function summary(record) { + return { id: record.Id, name: record.Name.replace(/^\//, ""), image: record.Config.Image, + running: Boolean(record.State.Running), project: record.Config.Labels?.["com.docker.compose.project"] || "", + service: record.Config.Labels?.["com.docker.compose.service"] || "" }; +} +export function sourceCandidates(records) { + return records.filter(record => /^[a-f0-9]{64}$/.test(record.Id) && record.Platform !== "windows").map(record => { + const mounts = (record.Mounts || []).map(mount => mount.Destination); + return { ...summary(record), application: mounts.includes("/app/storage") && mounts.includes("/app/config") && mounts.includes("/app/auth"), + database: (record.Config.Env || []).some(value => value.startsWith("POSTGRES_DB=")) || /(?:^|\/)postgres(?::|@)/.test(record.Config.Image || "") }; + }).filter(record => record.application || record.database); +} +export function validateSourcePair(app, database, config = {}) { + if (app.Id === database.Id || !database.State.Running) throw new Error("Choose a separate running Docker PostgreSQL database."); + if (!sourceCandidates([app]).some(record => record.application) || !sourceCandidates([database]).some(record => record.database)) throw new Error("Select an ALPR Docker application and PostgreSQL container."); + const a = envOf(app), d = envOf(database), settings = config.database || {}; + const host = a.DB_HOST || settings.host || "db:5432"; + const [hostname, portText = "5432"] = host.split(":"); + const port = Number(portText); + if (!Number.isInteger(port) || port < 1 || port > 65535 || d.PGPORT && Number(d.PGPORT) !== port) throw new Error("Unsupported Docker source database port."); + const shared = Object.keys(app.NetworkSettings?.Networks || {}).filter(name => database.NetworkSettings?.Networks?.[name]); + const names = [database.Name.replace(/^\//, ""), ...shared.flatMap(name => [database.NetworkSettings.Networks[name].IPAddress, ...(database.NetworkSettings.Networks[name].Aliases || [])])]; + if (!shared.length || !names.includes(hostname)) throw new Error("The selected database does not match the application's configured Docker database. No source was stopped."); + const password = a.DB_PASSWORD || settings.password || d.POSTGRES_PASSWORD; + if (!password) throw new Error("The source database credential is unavailable. Preserve the source and contact support."); + return { ...process.env, ALPR_PG_BIN_DIR: "", + ALPR_MIGRATION_DOCKER_SOURCE: database.Id, ALPR_MIGRATION_SOURCE_HOST: "127.0.0.1", + ALPR_MIGRATION_SOURCE_PORT: String(port), ALPR_MIGRATION_SOURCE_DATABASE: a.DB_NAME || settings.name || d.POSTGRES_DB || "postgres", + ALPR_MIGRATION_SOURCE_USER: a.DB_USER || settings.user || d.POSTGRES_USER || "postgres", + ALPR_MIGRATION_SOURCE_PASSWORD: password, ALPR_MIGRATION_SOURCE_SSLMODE: "disable" }; +} +async function inspect(id, run) { + if (!/^[a-f0-9]{64}$/.test(id || "")) throw new Error("Choose the full source container identity from the list."); + const records = JSON.parse((await run(["inspect", id])).stdout); + if (records.length !== 1 || records[0].Id !== id) throw new Error("Docker source identity changed."); + return records[0]; +} +async function copyDirectory(run, id, source, target) { + await mkdir(target); + await run(["cp", id + ":" + source + "/.", target], { timeout: 30 * 60_000 }); + await fileInventory(target); // Refuse links, junctions and non-regular files. +} +async function configuration(run, app, scratch) { + const target = path.join(scratch, "config"); + await copyDirectory(run, app.Id, "/app/config", target); + try { return require("js-yaml").load(await readFile(path.join(target, "settings.yaml"), "utf8")) || {}; } + catch (error) { if (error.code === "ENOENT") return {}; throw new Error("The source settings file cannot be read safely. Keep it private and contact support."); } +} +function plainConfig(config) { + if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("Invalid source settings."); + return config; +} +async function sourceEvidence(env, application) { + const endpoint = pg.readEndpoint(env, "ALPR_MIGRATION_SOURCE"); + const major = pg.parseServerMajor(await pg.query(endpoint, "SHOW server_version_num;", env)); + pg.assertSupportedSourceMajor(major); + const source = await pg.inspectSourceApplication(endpoint, env); + const counts = await pg.countTables(endpoint, await pg.listPublicTables(endpoint, env), env); + return { sourceProfile: source.profile.id, sourceLabel: source.profile.label, serverMajor: major, + schemaFingerprint: source.schemaFingerprint, tableCounts: counts, plateReads: counts.plate_reads || "0", application }; +} +async function prepare({ appId, databaseId, scratch }, run) { + const app = await inspect(appId, run), database = await inspect(databaseId, run); + const config = plainConfig(await configuration(run, app, scratch)); + const env = { ...validateSourcePair(app, database, config), ALPR_MIGRATION_DOCKER_SCRATCH: scratch }; + await run(["image", "inspect", POSTGRES_CLIENT_IMAGE]).catch(() => run(["pull", POSTGRES_CLIENT_IMAGE], { timeout: 10 * 60_000 })); + return { app, database, env, evidence: await sourceEvidence(env, summary(app)) }; +} +export async function listDockerSources(options = {}) { + const run = options.docker || docker; + const info = JSON.parse((await run(["info", "--format", "{{json .}}"])).stdout); + if (info.OSType !== "linux") throw new Error("Switch Docker Desktop to Linux containers. No source was changed."); + const ids = (await run(["ps", "--all", "--quiet", "--no-trunc"])).stdout.trim().split(/\s+/).filter(Boolean); + const records = []; + for (const id of ids) records.push(await inspect(id, run)); + return { engine: info.Name, containers: sourceCandidates(records) }; +} +export async function previewDockerExport({ appId, databaseId, workParent }, options = {}) { + await assertRealDirectory(workParent); + const scratch = await mkdtemp(path.join(workParent, "alpr-docker-preview-")); + try { + const result = await prepare({ appId, databaseId, scratch }, options.docker || docker); + return { ...result.evidence, database: summary(result.database), confirmation: appId + ":" + databaseId }; + } finally { await rm(scratch, { recursive: true, force: true }); } +} +export async function exportDockerMigration({ appId, databaseId, destination, confirmation, expected, writersPaused }, options = {}) { + if (writersPaused !== true || confirmation !== appId + ":" + databaseId || !expected?.sourceProfile || !expected?.tableCounts) throw new Error("Review the source counts and confirm other database writers are paused before exporting."); + destination = path.resolve(destination); + const parent = path.dirname(destination); + await assertRealDirectory(parent); + if (await lstat(destination).then(() => true, error => { if (error.code === "ENOENT") return false; throw error; })) throw new Error("Choose a new backup folder; existing backups are never overwritten."); + const lockPath = destination + ".export-lock"; + await mkdir(lockPath); + const scratch = await mkdtemp(path.join(parent, "alpr-docker-export-")); + const run = options.docker || docker; + let selected, restart = false, completed = false, sealed = false; + try { + const result = await prepare({ appId, databaseId, scratch }, run); + selected = result.app; + if (result.evidence.sourceProfile !== expected.sourceProfile || result.evidence.schemaFingerprint !== expected.schemaFingerprint) throw new Error("The source schema changed after review. Review it again."); + // Full IDs prevent a renamed/replaced container from being stopped by name. + if (selected.State.Running) { + restart = true; + await run(["stop", "--time", "60", selected.Id]); + } + const stopped = await inspect(appId, run); + if (stopped.State.Running) throw new Error("The selected source application did not stop."); + const env = { ...result.env, ALPR_MIGRATION_SOURCE_QUIESCED: pg.SOURCE_QUIESCED_ACKNOWLEDGEMENT, + ALPR_MIGRATION_DUMP_PATH: path.join(scratch, "postgres.dump") }; + await runPostgresMigrationCommand("dump", env); + const before = await sourceEvidence(env, summary(stopped)); + if (BigInt(before.plateReads) < BigInt(expected.plateReads)) throw new Error("The source has fewer plate records than the reviewed source. Preserve it and review again."); + const storage = path.join(scratch, "storage"), auth = path.join(scratch, "auth"); + await copyDirectory(run, appId, "/app/storage", storage); + await copyDirectory(run, appId, "/app/auth", auth); + const configCopy = path.join(scratch, "config-stopped"); + await copyDirectory(run, appId, "/app/config", configCopy); + const optional = async file => readFile(file).then(() => file, error => { if (error.code === "ENOENT") return undefined; throw error; }); + const manifest = await createMigrationBundle({ dumpPath: env.ALPR_MIGRATION_DUMP_PATH, storagePath: storage, + configPath: await optional(path.join(configCopy, "settings.yaml")), authPath: await optional(path.join(auth, "auth.json")), + destination, sourceQuiesced: true, configEnvironment: envOf(stopped) }); + sealed = true; + const second = path.join(scratch, "storage-check"); + await copyDirectory(run, appId, "/app/storage", second); + if (JSON.stringify(await fileInventory(storage)) !== JSON.stringify(await fileInventory(second))) throw new Error("Source images changed during export. Stop every writer and retry."); + const after = await sourceEvidence(env, summary(await inspect(appId, run))); + if ((await inspect(appId, run)).State.Running || JSON.stringify(before.tableCounts) !== JSON.stringify(after.tableCounts) || before.schemaFingerprint !== after.schemaFingerprint) throw new Error("The source changed during export. Stop every writer and retry."); + const verified = await verifyMigrationBundle(destination); + if (JSON.stringify(verified.database.source.tableCounts) !== JSON.stringify(after.tableCounts)) throw new Error("The dump does not match the stopped source."); + completed = true; + return { destination, sourceProfile: manifest.source, plateReads: after.plateReads, filesVerified: Object.keys(manifest.files).length, + application: summary(stopped), database: summary(result.database) }; + } finally { + if (sealed && !completed) await rm(path.join(destination, "migration-backup.json"), { force: true }); + try { + if (restart) { + await run(["start", selected.Id]); + if (!(await inspect(selected.Id, run)).State.Running) throw new Error("The backup operation ended, but the original Docker application did not restart. Start that selected container in Docker Desktop; its data is preserved."); + } + } finally { + await rm(scratch, { recursive: true, force: true }); + await rm(lockPath, { recursive: true, force: true }); + } + } +} +async function main() { + const [command, requestFile, resultFile] = process.argv.slice(2); + const request = command === "list" ? {} : JSON.parse(await readFile(requestFile, "utf8")); + const result = command === "list" ? await listDockerSources() + : command === "preview" ? await previewDockerExport(request) + : command === "export" ? await exportDockerMigration(request) : null; + if (!result) throw new Error("Use list, preview or export with private request and result files."); + await writeFile(resultFile || requestFile, JSON.stringify(result, null, 2) + "\n", { flag: "wx", mode: 0o600 }); +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch(error => { console.error("Docker migration export: " + error.message); process.exitCode = 1; }); +} diff --git a/scripts/docker-migration-interactive.mjs b/scripts/docker-migration-interactive.mjs new file mode 100644 index 0000000..483cb91 --- /dev/null +++ b/scripts/docker-migration-interactive.mjs @@ -0,0 +1,35 @@ +import { createInterface } from "node:readline/promises"; +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { randomUUID } from "node:crypto"; +import { listDockerSources, previewDockerExport, exportDockerMigration } from "./docker-migration-export.mjs"; +const input = createInterface({ input: process.stdin, output: process.stdout }); +try { + const sources = await listDockerSources(); + const apps = sources.containers.filter(item => item.application); + const databases = sources.containers.filter(item => item.database); + for (const [title, items] of [["ALPR applications", apps], ["PostgreSQL databases", databases]]) { + console.log(title); + items.forEach((item, index) => console.log(String(index + 1) + ": " + item.name + " | " + item.image)); + } + const app = apps[Number(await input.question("Original ALPR application number: ")) - 1]; + const database = databases[Number(await input.question("Original database number: ")) - 1]; + if (!app || !database) throw new Error("Select both source containers."); + const answer = await input.question("Private local backup directory (absolute path): "); + if (!path.isAbsolute(answer)) throw new Error("Use an absolute private backup path."); + const parent = path.resolve(answer); + if (!path.isAbsolute(parent)) throw new Error("Use an absolute private backup path."); + await mkdir(parent, { recursive: true, mode: 0o700 }); + const preview = await previewDockerExport({ appId: app.id, databaseId: database.id, workParent: parent }); + console.log(preview.sourceLabel + ", PostgreSQL " + preview.serverMajor + ", plate reads: " + preview.plateReads); + if (preview.plateReads === "0") console.log("WARNING: this source is empty. Cancel if you expected historical records."); + console.log("Pause every other database writer. The selected original Docker app will stop briefly and restart afterward."); + if (await input.question("Type EXPORT ORIGINAL DOCKER DATABASE to confirm: ") !== "EXPORT ORIGINAL DOCKER DATABASE") throw new Error("Cancelled without stopping the source."); + const destination = path.join(parent, "Docker-ALPR-" + randomUUID()); + const result = await exportDockerMigration({ appId: app.id, databaseId: database.id, destination, + confirmation: preview.confirmation, expected: preview, writersPaused: true }); + console.log("Verified backup: " + result.destination + os.EOL + "Copy the entire folder to Windows. Pause source ingestion again before import. The importer requires an empty target and does not merge existing records."); +} catch (error) { + console.error(error.message); process.exitCode = 1; +} finally { input.close(); } diff --git a/scripts/postgres-major-migration.mjs b/scripts/postgres-major-migration.mjs index 72ae68b..c072d42 100644 --- a/scripts/postgres-major-migration.mjs +++ b/scripts/postgres-major-migration.mjs @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { dockerPostgres } from "./docker-migration-client.mjs"; import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; import { access, chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises"; @@ -153,6 +154,7 @@ function clientEnvironment(endpoint, environment) { } function runProcess(program, args, { environment = process.env, inherit = false } = {}) { + if (environment.ALPR_MIGRATION_DOCKER_SOURCE) return dockerPostgres(program, args, environment); return new Promise((resolvePromise, reject) => { const child = spawn(program, args, { env: environment, diff --git a/scripts/test-docker-migration-export.mjs b/scripts/test-docker-migration-export.mjs new file mode 100644 index 0000000..b70a654 --- /dev/null +++ b/scripts/test-docker-migration-export.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, readFile, cp, rm } from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { randomUUID } from "node:crypto"; +import { docker } from "./docker-migration-client.mjs"; +import { listDockerSources, previewDockerExport, exportDockerMigration } from "./docker-migration-export.mjs"; +import { verifyMigrationBundle } from "./community-migration-bundle.mjs"; +import { hashFile } from "./windows-native-package.mjs"; +const workspace = await mkdtemp(path.join(os.tmpdir(), "alpr-docker-real-")); +const owned = [], networks = [], volumes = []; +const reference = "aeb72baf6f0435c8d42ed07422f1b2f3a703e6ac"; +async function original(file) { + const response = await fetch("https://raw.githubusercontent.com/algertc/ALPR-Database/" + reference + "/" + file); + if (!response.ok) throw new Error("Pinned Original baseline unavailable."); + return response.text(); +} +async function psql(id, sql) { + return (await docker(["exec", id, "psql", "-U", "postgres", "-d", "postgres", "-At", "--set", "ON_ERROR_STOP=1", "-c", sql])).stdout.trim(); +} +try { + for (const major of [13, 17]) { + const suffix = randomUUID(), network = "alpr-export-fixture-" + suffix; + await docker(["network", "create", network]); networks.push(network); + const password = randomUUID(); + const db = (await docker(["run", "-d", "--name", network + "-db", "--network", network, "--network-alias", "db", + "--env", "POSTGRES_PASSWORD", "postgres:" + (major === 13 ? "13" : "17.10")], { environment: { ...process.env, POSTGRES_PASSWORD: password }, timeout: 300_000 })).stdout.trim(); + owned.push(db); + for (let attempt = 0; ; attempt++) { + try { await psql(db, "SELECT 1;"); break; } + catch (error) { if (attempt === 60) throw error; await new Promise(resolve => setTimeout(resolve, 500)); } + } + for (const file of ["schema.sql", "migrations.sql"]) { + const local = path.join(workspace, major + "-" + file); await writeFile(local, await original(file)); + await docker(["cp", local, db + ":/tmp/" + file]); + await docker(["exec", db, "psql", "-U", "postgres", "-d", "postgres", "--set", "ON_ERROR_STOP=1", "--single-transaction", "-f", "/tmp/" + file]); + } + await psql(db, "INSERT INTO public.plates(plate_number,occurrence_count) VALUES ('TEST0001',1); INSERT INTO public.plate_reads(plate_number,camera_name,image_path,timestamp) VALUES ('TEST0001','Synthetic camera','images/test.jpg',CURRENT_TIMESTAMP);"); + const fixture = path.join(workspace, "files-" + major); + for (const folder of ["config", "auth", "storage/images"]) await mkdir(path.join(fixture, folder), { recursive: true }); + await writeFile(path.join(fixture, "config/settings.yaml"), "general:\n maxRecords: 4321\n"); + await writeFile(path.join(fixture, "auth/auth.json"), JSON.stringify({ password: "synthetic-hash", apiKey: "a".repeat(64), sessions: {} })); + await writeFile(path.join(fixture, "storage/images/test.jpg"), "synthetic image"); + const mounts = []; + for (const folder of ["config", "auth", "storage"]) { + const name = network + "-" + folder; await docker(["volume", "create", name]); volumes.push(name); + mounts.push("--mount", "type=volume,src=" + name + ",dst=/app/" + folder); + } + const app = (await docker(["create", "--name", network + "-app", "--network", network, ...mounts, + "--env", "DB_PASSWORD", "--env", "TZ=America/Denver", "alpine:3.23", "sleep", "3600"], + { environment: { ...process.env, DB_PASSWORD: password }, timeout: 300_000 })).stdout.trim(); + owned.push(app); + for (const folder of ["config", "auth", "storage"]) await docker(["cp", path.join(fixture, folder) + path.sep + ".", app + ":/app/" + folder]); + if (major === 13) await docker(["start", app]); + const listed = await listDockerSources(); + assert.ok(listed.containers.some(item => item.id === app && item.application)); + assert.ok(!JSON.stringify(listed).includes(password)); + const preview = await previewDockerExport({ appId: app, databaseId: db, workParent: workspace }); + assert.equal(preview.sourceProfile, "original-alpr-v0.1.9"); assert.equal(preview.plateReads, "1"); + const request = { appId: app, databaseId: db, destination: path.join(workspace, "backup-" + major), + confirmation: preview.confirmation, expected: preview, writersPaused: true }; + if (major === 13) { + let injected = false; + await assert.rejects(exportDockerMigration({ ...request, destination: path.join(workspace, "failed-" + major) }, + { docker(args, options) { if (!injected && args[0] === "cp" && args[1].includes(":/app/storage")) { injected = true; throw new Error("injected copy failure"); } return docker(args, options); } }), /injected/); + assert.equal(JSON.parse((await docker(["inspect", app])).stdout)[0].State.Running, true, "A failed backup must restart only its selected application."); + } + await exportDockerMigration(request); + const bundle = await verifyMigrationBundle(request.destination); + assert.equal(bundle.database.source.tableCounts.plate_reads, "1"); + assert.equal(bundle.files["storage/images/test.jpg"], await hashFile(path.join(fixture, "storage/images/test.jpg"))); + assert.equal(bundle.runtimeTimeZone, "America/Denver"); + assert.equal(JSON.parse((await docker(["inspect", app])).stdout)[0].State.Running, major === 13, "Preserve the source's original running/stopped state."); + assert.equal(await psql(db, "SELECT count(*) FROM public.plate_reads;"), "1"); + assert.equal(JSON.parse(await readFile(path.join(request.destination, "auth/auth.json"), "utf8")).apiKey, "a".repeat(64)); + if (process.env.ALPR_DOCKER_TEST_OUTPUT && major === 13) await cp(request.destination, path.resolve(process.env.ALPR_DOCKER_TEST_OUTPUT), { recursive: true, errorOnExist: true, force: false }); + console.log("Docker PostgreSQL " + major + " export passed source attestation, exact row counts, named-volume images/config/auth, restart recovery and unchanged source."); + } +} finally { + for (const id of owned.reverse()) await docker(["rm", "--force", id]).catch(() => {}); + for (const name of volumes) await docker(["volume", "rm", name]).catch(() => {}); + for (const name of networks) await docker(["network", "rm", name]).catch(() => {}); + await rm(workspace, { recursive: true, force: true }); +} diff --git a/scripts/test-windows-migration-runtime.mjs b/scripts/test-windows-migration-runtime.mjs index 42a38ba..af36cf0 100644 --- a/scripts/test-windows-migration-runtime.mjs +++ b/scripts/test-windows-migration-runtime.mjs @@ -109,6 +109,15 @@ try { assert.equal(source.sql("SELECT count(*) FROM public.plate_reads WHERE plate_number='MIG0001';"), "1"); console.log(baseline + " logical export/import passed exact pre-migration counts, source-schema attestation, upgrade policy, image checksums, settings, API-key preservation, chosen-password reset, nonempty-target refusal, source preservation, and real-SQL LPR source switching/claim/replay."); } + if (process.env.ALPR_DOCKER_TEST_BUNDLE) { + const target = await cluster('docker-portable-target', targetRelease); + const result = await restoreWindowsMigration({ bundleRoot: path.resolve(process.env.ALPR_DOCKER_TEST_BUNDLE) }, { deployment: target.deployment, confirmed: true }); + assert.equal(result.status, 'validated'); + assert.equal(target.sql("SELECT count(*) FROM public.plate_reads WHERE plate_number='TEST0001';"), '1'); + assert.equal(await readFile(path.join(target.data, 'storage/images/test.jpg'), 'utf8'), 'synthetic image'); + assert.equal(JSON.parse(await readFile(path.join(target.data, 'auth/auth.json'), 'utf8')).apiKey, 'a'.repeat(64)); + console.log('Real Linux Docker export restored into native Windows PostgreSQL with records, images and API key verified.'); + } } finally { for (const item of clusters.reverse()) item.run(path.join(pgBin, "pg_ctl.exe"), ["-D", item.database, "-w", "-m", "fast", "stop"]); if (path.dirname(root) !== path.resolve(os.tmpdir())) throw new Error("Unexpected test directory."); diff --git a/scripts/windows/CommunitySetup.iss b/scripts/windows/CommunitySetup.iss index 0669a14..392925a 100644 --- a/scripts/windows/CommunitySetup.iss +++ b/scripts/windows/CommunitySetup.iss @@ -79,6 +79,7 @@ Source: "{#PackageRoot}\LICENSE"; DestDir: "{app}"; Flags: ignoreversion [Icons] Name: "{commonprograms}\ALPR Database Community"; Filename: "{code:GetOpenAlprUrl}"; Comment: "Open ALPR Database Community" +Name: "{commonprograms}\ALPR Docker Migration Backup"; Filename: "{sys}\WindowsPowerShell\v1.0\powershell.exe"; Parameters: "-NoProfile -STA -ExecutionPolicy Bypass -WindowStyle Hidden -File ""{autopf}\ALPR Community\host\ExportDockerMigration.ps1"""; Comment: "Export the original Docker ALPR database and images" Name: "{commonprograms}\ALPR Migration Backup"; Filename: "{sys}\WindowsPowerShell\v1.0\powershell.exe"; Parameters: "-NoProfile -STA -ExecutionPolicy Bypass -WindowStyle Hidden -File ""{autopf}\ALPR Community\host\ExportMigration.ps1"""; Comment: "Make a verified ALPR backup for another computer" Name: "{commondesktop}\ALPR Database Community"; Filename: "{code:GetOpenAlprUrl}"; Comment: "Open ALPR Database Community" @@ -96,6 +97,7 @@ var PasswordPage: TInputQueryWizardPage; InstallModePage: TInputOptionWizardPage; MigrationPage: TInputDirWizardPage; + DockerBackupButton: TNewButton; PortsPage: TInputQueryWizardPage; InstalledAppPort: Integer; SourceStoppedCheck: TNewCheckBox; @@ -155,6 +157,8 @@ end; function ShouldSkipPage(PageID: Integer): Boolean; forward; +procedure DockerBackupClick(Sender: TObject); forward; + procedure InitializeWizard; begin InstallModePage := CreateInputOptionPage(wpWelcome, 'Set up ALPR', 'Choose how to start', @@ -172,7 +176,7 @@ begin InstallModePage.SelectedValueIndex := 2; #endif MigrationPage := CreateInputDirPage(InstallModePage.ID, 'Move your ALPR data', 'Choose your migration backup folder', - 'Select the folder containing migration-backup.json. The backup and original computer remain available for recovery.', False, ''); + 'Select the folder containing migration-backup.json. For an original Docker installation on this Windows computer, create its backup below first. For Linux, export on the Linux host and copy the entire folder here.', False, ''); MigrationPage.Add('ALPR migration backup:'); SourceStoppedCheck := TNewCheckBox.Create(WizardForm); SourceStoppedCheck.Parent := MigrationPage.Surface; @@ -180,6 +184,13 @@ begin SourceStoppedCheck.Width := MigrationPage.SurfaceWidth; SourceStoppedCheck.Height := ScaleY(36); SourceStoppedCheck.Caption := 'I have paused ingestion on the old ALPR installation'; + DockerBackupButton := TNewButton.Create(WizardForm); + DockerBackupButton.Parent := MigrationPage.Surface; + DockerBackupButton.Top := SourceStoppedCheck.Top + SourceStoppedCheck.Height + ScaleY(8); + DockerBackupButton.Width := ScaleX(290); + DockerBackupButton.Height := ScaleY(30); + DockerBackupButton.Caption := 'Back up original Docker ALPR...'; + DockerBackupButton.OnClick := @DockerBackupClick; ProgressPage := CreateOutputProgressPage('Setting up ALPR', 'Please wait while Setup completes the current step'); PortsPage := CreateInputQueryPage(MigrationPage.ID, 'Connection ports', 'Choose how to connect to this installation', @@ -224,6 +235,7 @@ begin (PasswordPage.Values[1] <> '') or (WorkRoot = '') or NetworkAccessCheck.Checked or (NetworkAccessCheck.Top + NetworkAccessCheck.Height > PasswordPage.SurfaceHeight) or (SourceStoppedCheck.Top + SourceStoppedCheck.Height > MigrationPage.SurfaceHeight) or + (DockerBackupButton.Top + DockerBackupButton.Height > MigrationPage.SurfaceHeight) or SourceStoppedCheck.Checked or (InstallModePage.SelectedValueIndex <> 0) or (ProgressPage.ProgressBar.Position <> 37) then RaiseException('Startup probe did not initialize the password page and workspace.'); @@ -340,7 +352,7 @@ begin Parameters := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "' + ExpandConstant('{tmp}\Setup.ps1') + '" -Operation ' + Operation + ' -WorkRoot "' + WorkRoot + '"'; - if Operation = 'prepare' then + if (Operation = 'prepare') or (Operation = 'verify') then Parameters := Parameters + ' -PackageRoot "' + ExpandConstant('{tmp}\payload') + '" -ManifestSha256 {#ManifestSha256}'; if ((Operation = 'prepare') or (Operation = 'check-ports')) and @@ -359,6 +371,39 @@ begin ewWaitUntilTerminated, ResultCode, @SetupOutput); end; +procedure DockerBackupClick(Sender: TObject); +var + ResultCode: Integer; + ResultFile, BackupPath, Parameters: String; +begin +#ifndef StartupProbe + DockerBackupButton.Enabled := False; + try + ExtractTemporaryFile('Setup.ps1'); + ExtractTemporaryFile('Setup-Helpers.ps1'); + ExtractTemporaryFiles('{tmp}\payload\*'); + if not RunSetupOperation('verify', ResultCode) or (ResultCode <> 0) or (LastError <> '') then begin + MsgBox('The exporter payload could not be verified. Keep the setup log.', mbError, MB_OK); + Exit; + end; + ResultFile := ExpandConstant('{tmp}\docker-export-result.txt'); + DeleteFile(ResultFile); + Parameters := '-NoProfile -STA -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + + ExpandConstant('{tmp}\payload\host\ExportDockerMigration.ps1') + '" -PackageRoot "' + + ExpandConstant('{tmp}\payload') + '" -ResultFile "' + ResultFile + '"'; + if Exec(Powershell, Parameters, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and + (ResultCode = 0) and LoadStringFromFile(ResultFile, BackupPath) then begin + MigrationPage.Values[0] := Trim(BackupPath); + SourceStoppedCheck.Checked := False; + MsgBox('Your Docker backup is selected. Pause ingestion on the original source again before continuing. Import requires an empty native target and cannot merge an existing Community database.', mbInformation, MB_OK); + end; + DeleteFile(ResultFile); + finally + DockerBackupButton.Enabled := True; + end; +#endif +end; + function PrepareToInstall(var NeedsRestart: Boolean): String; var ResultCode: Integer; diff --git a/scripts/windows/ExportDockerMigration.ps1 b/scripts/windows/ExportDockerMigration.ps1 new file mode 100644 index 0000000..82be0dd --- /dev/null +++ b/scripts/windows/ExportDockerMigration.ps1 @@ -0,0 +1,92 @@ +#requires -Version 5.1 +param([string]$PackageRoot, [string]$ResultFile) +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Windows.Forms +. (Join-Path $PSScriptRoot 'Setup-Helpers.ps1') +$form = $null +$work = $null +try { + Assert-SetupHost + if (-not $PackageRoot) { + $root = Assert-SetupDirectory "$env:ProgramFiles\ALPR Community" + $installation = Get-Content -Raw -LiteralPath (Join-Path $root 'installation.json') | ConvertFrom-Json + if ($installation.profile -ne 'windows-native' -or $installation.installRoot -ne $root -or $installation.current -notmatch '^\d+\.\d+\.\d+-[0-9a-f]{12}$') { throw 'Invalid installed ALPR identity' } + $PackageRoot = Join-Path $root ('releases\' + $installation.current) + } + $PackageRoot = Assert-SetupDirectory $PackageRoot + $node = Join-Path $PackageRoot 'runtime\node.exe' + $script = Join-Path $PackageRoot 'host\docker-migration-export.mjs' + $work = Join-Path $env:LOCALAPPDATA ('ALPR Docker Export\' + [Guid]::NewGuid().ToString('N')) + Protect-SetupDirectory (Split-Path -Parent $work) + Protect-SetupDirectory $work + function Invoke-Exporter([string]$Command, [object]$Request) { + $id = [Guid]::NewGuid().ToString('N') + $requestFile = Join-Path $work ($id + '-request.json') + $outputFile = Join-Path $work ($id + '-result.json') + [IO.File]::WriteAllText($requestFile, ($Request | ConvertTo-Json -Depth 20), (New-Object Text.UTF8Encoding($false))) + $arguments = @(('"' + $script + '"'),$Command,('"' + $requestFile + '"'),('"' + $outputFile + '"')) + $process = Start-Process -FilePath $node -ArgumentList $arguments -PassThru -WindowStyle Hidden -RedirectStandardOutput (Join-Path $work ($id + '-output.log')) -RedirectStandardError (Join-Path $work ($id + '-error.log')) + $exitCode = Wait-SetupChildProcess $process { [Windows.Forms.Application]::DoEvents() } + if ($exitCode -ne 0) { + $detail = Get-Content -Raw -LiteralPath (Join-Path $work ($id + '-error.log')) + throw ($detail + [Environment]::NewLine + 'Your source data is preserved. Diagnostic folder: ' + $work) + } + return (Get-Content -Raw -LiteralPath $outputFile | ConvertFrom-Json) + } + $form = New-Object Windows.Forms.Form + $form.Text = 'Back up ALPR from Docker' + $form.Width = 740; $form.Height = 435; $form.StartPosition = 'CenterScreen' + $label = New-Object Windows.Forms.Label + $label.Text = 'Reading Docker containers. Keep Docker Desktop running in Linux-container mode.' + $label.SetBounds(20,15,680,60) + $form.Controls.Add($label); $form.Show() + $sources = Invoke-Exporter 'list' @{} + $label.Text = 'Choose the ORIGINAL Docker ALPR application and its PostgreSQL container. This does not select or back up native Windows Community. Database inspection may download PostgreSQL client tools into Docker.' + $apps = @($sources.containers | Where-Object { $_.application }) + $databases = @($sources.containers | Where-Object { $_.database }) + if ($apps.Count -eq 0 -or $databases.Count -eq 0) { throw 'No supported Docker ALPR application/database pair found. Run this on the computer that hosts the original Docker installation.' } + $appLabel = New-Object Windows.Forms.Label; $appLabel.Text = 'Original ALPR application:'; $appLabel.SetBounds(20,85,650,22) + $appBox = New-Object Windows.Forms.ComboBox; $appBox.DropDownStyle = 'DropDownList'; $appBox.SetBounds(20,110,680,28) + foreach ($item in $apps) { [void]$appBox.Items.Add($item.name + ' | ' + $item.image) } + $dbLabel = New-Object Windows.Forms.Label; $dbLabel.Text = 'Original PostgreSQL database:'; $dbLabel.SetBounds(20,150,650,22) + $dbBox = New-Object Windows.Forms.ComboBox; $dbBox.DropDownStyle = 'DropDownList'; $dbBox.SetBounds(20,175,680,28) + foreach ($item in $databases) { [void]$dbBox.Items.Add($item.name + ' | ' + $item.image) } + $notice = New-Object Windows.Forms.Label; $notice.SetBounds(20,220,680,65) + $notice.Text = 'The next step shows the source version and record count. Export pauses only the selected Docker app and restarts it afterward. Other database writers must be paused. Existing native Community data is never replaced or merged.' + $button = New-Object Windows.Forms.Button; $button.Text = 'Review source'; $button.SetBounds(410,315,140,35); $button.DialogResult = 'OK' + $cancel = New-Object Windows.Forms.Button; $cancel.Text = 'Cancel'; $cancel.SetBounds(565,315,130,35); $cancel.DialogResult = 'Cancel' + $form.Controls.AddRange(@($appLabel,$appBox,$dbLabel,$dbBox,$notice,$button,$cancel)) + $form.AcceptButton = $button; $form.CancelButton = $cancel + # Deliberate selection prevents silently choosing an empty Community source. + $form.Hide() + if ($form.ShowDialog() -ne 'OK') { exit 0 } + if ($appBox.SelectedIndex -lt 0 -or $dbBox.SelectedIndex -lt 0) { throw 'Choose both source containers, then run this tool again.' } + $app = $apps[$appBox.SelectedIndex]; $database = $databases[$dbBox.SelectedIndex] + $form.Controls.Clear(); $form.ControlBox = $false + $label = New-Object Windows.Forms.Label; $label.SetBounds(20,20,680,65); $label.Text = 'Inspecting the selected Docker database. No source application has been stopped.' + $bar = New-Object Windows.Forms.ProgressBar; $bar.SetBounds(20,95,680,22); $bar.Style = 'Marquee' + $form.Controls.AddRange(@($label,$bar)); $form.Show() + $request = @{appId=$app.id;databaseId=$database.id;workParent=$work} + $preview = Invoke-Exporter 'preview' $request + $form.Hide() + $message = "Source application: $($app.name)`r`nDatabase container: $($database.name)`r`n$($preview.sourceLabel) on PostgreSQL $($preview.serverMajor)`r`nPlate reads: $($preview.plateReads)`r`n`r`nIs this the original database you want? Pause every OTHER database writer before continuing. ALPR will stop only this Docker application, verify the backup, then restart it." + if ($preview.plateReads -eq '0') { $message += "`r`n`r`nWARNING: this source has ZERO plate reads. Cancel if you expected historical records." } + if ([Windows.Forms.MessageBox]::Show($message,'Confirm original Docker source','OKCancel','Warning') -ne 'OK') { exit 0 } + $dialog = New-Object Windows.Forms.FolderBrowserDialog + $dialog.Description = 'Choose a private local folder for the Docker ALPR backup' + if ($dialog.ShowDialog() -ne 'OK') { exit 0 } + $parent = Join-Path (Assert-SetupDirectory $dialog.SelectedPath) 'ALPR Docker Migration Backups' + Protect-SetupDirectory $parent + $destination = Join-Path $parent ('Docker-ALPR-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + [Guid]::NewGuid().ToString('N').Substring(0,8)) + $request = @{appId=$app.id;databaseId=$database.id;destination=$destination;confirmation=$preview.confirmation;expected=$preview;writersPaused=$true} + $label.Text = 'Exporting and verifying database, images and settings. Keep this window open; the selected original Docker app will restart when finished.' + $form.Show() + $result = Invoke-Exporter 'export' $request + $form.Close(); $form = $null + if ($ResultFile) { [IO.File]::WriteAllText($ResultFile, $result.destination, (New-Object Text.UTF8Encoding($false))) } + [void][Windows.Forms.MessageBox]::Show("Verified Docker backup: $($result.destination)`r`nPlate reads: $($result.plateReads)`r`n`r`nKeep the entire folder. For Windows import choose Move an existing ALPR database and images. Pause source ingestion again before importing. Existing native data cannot be merged; back it up before planning a fresh target.",'Docker backup complete','OK','Information') +} catch { + if ($form) { $form.Close(); $form = $null } + [void][Windows.Forms.MessageBox]::Show($_.Exception.Message,'Docker migration backup','OK','Error') + exit 1 +} finally { if ($form) { $form.Close() } } diff --git a/scripts/windows/ExportMigration.ps1 b/scripts/windows/ExportMigration.ps1 index f3ebbc4..7eedffc 100644 --- a/scripts/windows/ExportMigration.ps1 +++ b/scripts/windows/ExportMigration.ps1 @@ -13,7 +13,7 @@ try { Assert-SetupHost $root = Assert-SetupDirectory "$env:ProgramFiles\ALPR Community" if ([IO.Path]::GetFullPath($PSScriptRoot) -ne (Join-Path $root 'host')) { throw 'Run the installed ALPR Migration Backup shortcut' } - $choice = [Windows.Forms.MessageBox]::Show('ALPR will pause briefly while it makes a verified copy of your database, images and settings, then restart. Choose a local folder for the backup.','ALPR Migration Backup','OKCancel','Information') + $choice = [Windows.Forms.MessageBox]::Show('This backs up native Windows Community only. For an original Docker database use ALPR Docker Migration Backup or the Docker backup button in Setup. Native ALPR will pause briefly, then restart. Choose a local folder for its backup.','ALPR Migration Backup','OKCancel','Information') if ($choice -ne 'OK') { exit } $dialog = New-Object Windows.Forms.FolderBrowserDialog $dialog.Description = 'Choose where to save your ALPR migration backup' diff --git a/test/docker-migration-export.test.mjs b/test/docker-migration-export.test.mjs new file mode 100644 index 0000000..fdd74fa --- /dev/null +++ b/test/docker-migration-export.test.mjs @@ -0,0 +1,74 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { mkdtemp, rm, readFile } from "node:fs/promises"; +import os from "node:os"; +import { sourceCandidates, validateSourcePair, exportDockerMigration } from "../scripts/docker-migration-export.mjs"; +import { dockerPostgresArguments, POSTGRES_CLIENT_IMAGE } from "../scripts/docker-migration-client.mjs"; +const appId = "a".repeat(64), dbId = "b".repeat(64); +function fixtures() { + const app = { Id: appId, Name: "/original-app", Platform: "linux", State: { Running: true }, + Config: { Image: "algertc/alpr-dashboard:latest", Env: ["DB_PASSWORD=private-fixture"], Labels: {} }, + Mounts: ["/app/auth", "/app/config", "/app/storage"].map(Destination => ({ Destination })), + NetworkSettings: { Networks: { original: {} } } }; + const database = { Id: dbId, Name: "/original-db", Platform: "linux", State: { Running: true }, + Config: { Image: "postgres:13", Env: ["POSTGRES_PASSWORD=private-fixture"], Labels: {} }, + NetworkSettings: { Networks: { original: { Aliases: ["db"] } } } }; + return { app, database }; +} +test("discovery distinguishes Docker applications/databases and never lists native service metadata", () => { + const { app, database } = fixtures(); + const result = sourceCandidates([app, database]); + assert.equal(result[0].application, true); assert.equal(result[1].database, true); + assert.ok(!JSON.stringify(result).includes("private-fixture")); + assert.deepEqual(sourceCandidates([{ ...app, Platform: "windows" }]), []); +}); +test("source pairing follows the selected application's configured network alias, not a guessed local port", () => { + const { app, database } = fixtures(); + assert.equal(validateSourcePair(app, database).ALPR_MIGRATION_DOCKER_SOURCE, dbId); + assert.throws(() => validateSourcePair(app, { ...database, NetworkSettings: { Networks: { other: {} } } }), /does not match/); + assert.throws(() => validateSourcePair(app, { ...database, State: { Running: false } }), /running/); + assert.throws(() => validateSourcePair(app, { ...database, Id: appId }), /separate/); + assert.throws(() => validateSourcePair(app, database, { database: { host: "foreign:5432" } }), /does not match/); +}); +test("client transport uses a PostgreSQL 17 client, read-only sessions, no published ports, and no password argument", () => { + const scratch = path.resolve(os.tmpdir(), "private export"); + const env = { ALPR_MIGRATION_DOCKER_SOURCE: dbId, ALPR_MIGRATION_DOCKER_SCRATCH: scratch }; + const args = dockerPostgresArguments("pg_dump.exe", ["--format=custom", "--file", path.join(scratch, "postgres.dump")], env); + assert.ok(args.includes(POSTGRES_CLIENT_IMAGE)); assert.ok(args.includes("container:" + dbId)); + assert.ok(args.includes("PGPASSWORD")); assert.ok(args.some(value => value.includes("default_transaction_read_only=on"))); + assert.ok(!args.includes("--publish") && !args.includes("--privileged")); + assert.equal(args.at(-1), "/export/postgres.dump"); + assert.throws(() => dockerPostgresArguments("pg_restore", ["--dbname", "postgres"], env), /cannot restore/); + assert.throws(() => dockerPostgresArguments("pg_dump", ["--file", path.resolve("unrelated")], env), /unexpected file/); + assert.throws(() => dockerPostgresArguments("psql", ["--file", "anything"], env), /unexpected file/); + assert.throws(() => dockerPostgresArguments("pg_dump", [], { ...env, ALPR_MIGRATION_DOCKER_SOURCE: "--host" }), /context/); +}); +test("export refuses unreviewed or unpaused sources before invoking Docker", async () => { + const call = { appId, databaseId: dbId, destination: path.resolve("unused") }; + for (const request of [call, { ...call, confirmation: appId + ":" + dbId, writersPaused: true }]) { + await assert.rejects(exportDockerMigration(request, { docker() { assert.fail("must not touch Docker"); } }), /Review/); + } +}); +test("an existing destination is preserved before source inspection or stopping", async t => { + const root = await mkdtemp(path.join(os.tmpdir(), "alpr-docker-refusal-")); + t.after(() => rm(root, { recursive: true, force: true })); + await assert.rejects(exportDockerMigration({ appId, databaseId: dbId, destination: root, + confirmation: appId + ":" + dbId, writersPaused: true, expected: { sourceProfile: "original-alpr-v0.1.9", tableCounts: {} } }, + { docker() { assert.fail("must not touch Docker"); } }), /never overwritten/); +}); +test("Windows exporter script parses with inbox PowerShell and installer exposes export before fresh installation", async () => { + const source = await readFile(new URL("../scripts/windows/CommunitySetup.iss", import.meta.url), "utf8"); + assert.ok(source.includes("DockerBackupButton.OnClick := @DockerBackupClick")); + const button = source.slice(source.indexOf("procedure DockerBackupClick(Sender: TObject);\nvar"), source.indexOf("function PrepareToInstall")); + assert.ok(button.includes("RunSetupOperation('verify'")); + assert.ok(!button.includes("RunSetupOperation('install'")); + if (process.platform === "win32") { + const { spawnSync } = await import("node:child_process"); + const script = path.resolve(import.meta.dirname, "../scripts/windows/ExportDockerMigration.ps1").replaceAll("'", "''"); + const result = spawnSync(path.join(process.env.SystemRoot, "System32/WindowsPowerShell/v1.0/powershell.exe"), + ["-NoProfile", "-NonInteractive", "-Command", "$e=$null;$t=$null;[void][Management.Automation.Language.Parser]::ParseFile('" + script + "',[ref]$t,[ref]$e);if($e){$e|Out-String|Write-Error;exit 1}"], + { encoding: "utf8", windowsHide: true }); + assert.equal(result.status, 0, result.stderr); + } +}); diff --git a/test/help-manual.test.mjs b/test/help-manual.test.mjs index a5c7a99..012133e 100644 --- a/test/help-manual.test.mjs +++ b/test/help-manual.test.mjs @@ -18,7 +18,7 @@ async function source(path) { } test("the user guide is structured, searchable, and role-aware", () => { - assert.equal(HELP_MANUAL.manualVersion, "3.23"); + assert.equal(HELP_MANUAL.manualVersion, "3.24"); assert.ok(HELP_MANUAL.sections.length >= 24); const ids = HELP_MANUAL.sections.map((section) => section.id); @@ -395,6 +395,6 @@ test("Windows installation guidance matches Setup choices and shipped platform s assert.match(issueForm, /Native Windows 10 22H2 x64/); assert.match(issueForm, /Native Windows 11 x64/); assert.match(issueForm, /Settings → Release/); - assert.ok(manualText.includes("create a portable windows backup")); + assert.ok(manualText.includes("create a portable native windows backup")); assert.ok(manualText.includes("localhost refers to the computer")); }); From 44aef5e2aafb2fc0e795e27141d63c51db09fe1b Mon Sep 17 00:00:00 2001 From: prsmith777 Date: Wed, 21 Oct 2026 18:13:02 -0600 Subject: [PATCH 02/12] Seal Docker backups only after final source validation --- scripts/community-migration-bundle.mjs | 3 ++- scripts/docker-migration-client.mjs | 2 +- scripts/docker-migration-export.mjs | 32 ++++++++++++++---------- scripts/test-docker-migration-export.mjs | 1 + test/docker-migration-export.test.mjs | 1 + test/release-info.test.mjs | 2 +- test/windows-migration.test.mjs | 6 +++++ 7 files changed, 31 insertions(+), 16 deletions(-) diff --git a/scripts/community-migration-bundle.mjs b/scripts/community-migration-bundle.mjs index 08fea0d..eca0751 100644 --- a/scripts/community-migration-bundle.mjs +++ b/scripts/community-migration-bundle.mjs @@ -60,7 +60,7 @@ export async function verifyMigrationBundle(root) { return { ...manifest, database }; } -export async function createMigrationBundle({ dumpPath, storagePath, configPath, authPath, destination, sourceQuiesced, configEnvironment = {} }) { +export async function createMigrationBundle({ dumpPath, storagePath, configPath, authPath, destination, sourceQuiesced, configEnvironment = {}, beforeSeal = async () => {} }) { if (sourceQuiesced !== true) throw new Error("Stop source ALPR and database writers before creating a migration backup."); destination = path.resolve(destination); separate(path.resolve(dumpPath), destination); @@ -111,6 +111,7 @@ export async function createMigrationBundle({ dumpPath, storagePath, configPath, } const files = await fileInventory(destination); validateInventory(files); + await beforeSeal(); // Exporters can recheck a stopped external source before publishing the completion seal. const manifest = { format: FORMAT, formatVersion: 1, createdAt: new Date().toISOString(), sourceQuiesced: true, source: database.source.applicationProfile, ...(timeZone ? { runtimeTimeZone: timeZone } : {}), files }; await writeFile(path.join(destination, MANIFEST), JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600, flag: "wx" }); diff --git a/scripts/docker-migration-client.mjs b/scripts/docker-migration-client.mjs index 1d76271..57b7aa1 100644 --- a/scripts/docker-migration-client.mjs +++ b/scripts/docker-migration-client.mjs @@ -19,7 +19,7 @@ export function docker(args, { environment = process.env, timeout = 120_000 } = child.on("close", code => { clearTimeout(timer); if (code === 0) resolve({ stdout, stderr }); - else reject(new Error("Docker operation failed (" + String(args[0]) + "). Check Docker is running and the selected source is available.")); + else reject(new Error("Docker operation failed (" + String(args[0]) + "). Check Docker is running and the selected source is available.", { cause: { code, stderr } })); }); }); } diff --git a/scripts/docker-migration-export.mjs b/scripts/docker-migration-export.mjs index be5279d..6596d51 100644 --- a/scripts/docker-migration-export.mjs +++ b/scripts/docker-migration-export.mjs @@ -28,6 +28,9 @@ export function validateSourcePair(app, database, config = {}) { if (app.Id === database.Id || !database.State.Running) throw new Error("Choose a separate running Docker PostgreSQL database."); if (!sourceCandidates([app]).some(record => record.application) || !sourceCandidates([database]).some(record => record.database)) throw new Error("Select an ALPR Docker application and PostgreSQL container."); const a = envOf(app), d = envOf(database), settings = config.database || {}; + for (const [variable, field] of [["DB_HOST", "host"], ["DB_NAME", "name"], ["DB_USER", "user"], ["DB_PASSWORD", "password"]]) { + if (a[variable] && settings[field] && a[variable] !== settings[field]) throw new Error("The source environment and saved database settings differ. Use the advanced migration procedure to identify the effective source; no application was stopped."); + } const host = a.DB_HOST || settings.host || "db:5432"; const [hostname, portText = "5432"] = host.split(":"); const port = Number(portText); @@ -107,7 +110,7 @@ export async function exportDockerMigration({ appId, databaseId, destination, co await mkdir(lockPath); const scratch = await mkdtemp(path.join(parent, "alpr-docker-export-")); const run = options.docker || docker; - let selected, restart = false, completed = false, sealed = false; + let selected, restart = false; try { const result = await prepare({ appId, databaseId, scratch }, run); selected = result.app; @@ -130,22 +133,25 @@ export async function exportDockerMigration({ appId, databaseId, destination, co const configCopy = path.join(scratch, "config-stopped"); await copyDirectory(run, appId, "/app/config", configCopy); const optional = async file => readFile(file).then(() => file, error => { if (error.code === "ENOENT") return undefined; throw error; }); + const sameInventory = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort()); const manifest = await createMigrationBundle({ dumpPath: env.ALPR_MIGRATION_DUMP_PATH, storagePath: storage, configPath: await optional(path.join(configCopy, "settings.yaml")), authPath: await optional(path.join(auth, "auth.json")), - destination, sourceQuiesced: true, configEnvironment: envOf(stopped) }); - sealed = true; - const second = path.join(scratch, "storage-check"); - await copyDirectory(run, appId, "/app/storage", second); - if (JSON.stringify(await fileInventory(storage)) !== JSON.stringify(await fileInventory(second))) throw new Error("Source images changed during export. Stop every writer and retry."); - const after = await sourceEvidence(env, summary(await inspect(appId, run))); - if ((await inspect(appId, run)).State.Running || JSON.stringify(before.tableCounts) !== JSON.stringify(after.tableCounts) || before.schemaFingerprint !== after.schemaFingerprint) throw new Error("The source changed during export. Stop every writer and retry."); - const verified = await verifyMigrationBundle(destination); - if (JSON.stringify(verified.database.source.tableCounts) !== JSON.stringify(after.tableCounts)) throw new Error("The dump does not match the stopped source."); - completed = true; - return { destination, sourceProfile: manifest.source, plateReads: after.plateReads, filesVerified: Object.keys(manifest.files).length, + destination, sourceQuiesced: true, configEnvironment: envOf(stopped), beforeSeal: async () => { + for (const [name, first] of [["storage", storage], ["config", configCopy], ["auth", auth]]) { + const second = path.join(scratch, name + "-check"); + await copyDirectory(run, appId, "/app/" + name, second); + if (!sameInventory(await fileInventory(first), await fileInventory(second))) throw new Error("Source files changed during export. Stop every writer and retry."); + } + const current = await inspect(appId, run); + const after = await sourceEvidence(env, summary(current)); + if (current.State.Running || !sameInventory(before.tableCounts, after.tableCounts) || before.schemaFingerprint !== after.schemaFingerprint) throw new Error("The source changed during export. Stop every writer and retry."); + const verified = await pg.readVerifiedManifest(env.ALPR_MIGRATION_DUMP_PATH); + if (!sameInventory(verified.manifest.source.tableCounts, after.tableCounts)) throw new Error("The dump does not match the stopped source."); + } }); + await verifyMigrationBundle(destination); + return { destination, sourceProfile: manifest.source, plateReads: before.plateReads, filesVerified: Object.keys(manifest.files).length, application: summary(stopped), database: summary(result.database) }; } finally { - if (sealed && !completed) await rm(path.join(destination, "migration-backup.json"), { force: true }); try { if (restart) { await run(["start", selected.Id]); diff --git a/scripts/test-docker-migration-export.mjs b/scripts/test-docker-migration-export.mjs index b70a654..6542cc9 100644 --- a/scripts/test-docker-migration-export.mjs +++ b/scripts/test-docker-migration-export.mjs @@ -30,6 +30,7 @@ try { try { await psql(db, "SELECT 1;"); break; } catch (error) { if (attempt === 60) throw error; await new Promise(resolve => setTimeout(resolve, 500)); } } + console.log("Preparing isolated Original schema on PostgreSQL " + major); for (const file of ["schema.sql", "migrations.sql"]) { const local = path.join(workspace, major + "-" + file); await writeFile(local, await original(file)); await docker(["cp", local, db + ":/tmp/" + file]); diff --git a/test/docker-migration-export.test.mjs b/test/docker-migration-export.test.mjs index fdd74fa..4deebc5 100644 --- a/test/docker-migration-export.test.mjs +++ b/test/docker-migration-export.test.mjs @@ -30,6 +30,7 @@ test("source pairing follows the selected application's configured network alias assert.throws(() => validateSourcePair(app, { ...database, State: { Running: false } }), /running/); assert.throws(() => validateSourcePair(app, { ...database, Id: appId }), /separate/); assert.throws(() => validateSourcePair(app, database, { database: { host: "foreign:5432" } }), /does not match/); + assert.throws(() => validateSourcePair(app, database, { database: { password: "different-fixture" } }), /settings differ/); }); test("client transport uses a PostgreSQL 17 client, read-only sessions, no published ports, and no password argument", () => { const scratch = path.resolve(os.tmpdir(), "private export"); diff --git a/test/release-info.test.mjs b/test/release-info.test.mjs index 8f8e16a..8bb69b8 100644 --- a/test/release-info.test.mjs +++ b/test/release-info.test.mjs @@ -24,7 +24,7 @@ test("release information resolves a commit-pinned deployment image", () => { }); assert.equal(release.version, "0.1.55"); - assert.equal(release.manualVersion, "3.23"); + assert.equal(release.manualVersion, "3.24"); assert.equal(release.manualUpdatedAt, "October 7, 2026"); assert.equal(release.gitSha, "8cd2fa8"); assert.equal(release.channel, "staging"); diff --git a/test/windows-migration.test.mjs b/test/windows-migration.test.mjs index ebe8529..f809784 100644 --- a/test/windows-migration.test.mjs +++ b/test/windows-migration.test.mjs @@ -72,3 +72,9 @@ test("legacy paths become portable without accepting traversal or external image assert.throws(() => normalizeMigratedStorageReference(input)); } }); + +test("a changed external source cannot publish a completed migration seal", async (t) => { + const f = await fixture(t); + await assert.rejects(createMigrationBundle({ ...f, beforeSeal() { throw new Error("source changed"); } }), /source changed/); + await assert.rejects(readFile(path.join(f.destination, "migration-backup.json")), { code: "ENOENT" }); +}); \ No newline at end of file From a6b07ad9427536fd31ab9aafa4608c0d9f463e57 Mon Sep 17 00:00:00 2001 From: prsmith777 Date: Wed, 21 Oct 2026 18:13:47 -0600 Subject: [PATCH 03/12] Keep Linux backup files owned by the exporting user --- scripts/docker-migration-client.mjs | 1 + scripts/docker-migration-export.mjs | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/docker-migration-client.mjs b/scripts/docker-migration-client.mjs index 57b7aa1..296c45f 100644 --- a/scripts/docker-migration-client.mjs +++ b/scripts/docker-migration-client.mjs @@ -40,6 +40,7 @@ export function dockerPostgresArguments(program, args, env) { if (tool === "psql" && !mapped.includes("--version") && !mapped.includes("--command")) throw new Error("Docker export accepts read-only source queries only."); return ["run", "--rm", "--name", "alpr-export-client-" + randomUUID(), "--label", "org.alpr.migration-client=true", "--network", "container:" + source, + ...(process.platform === "linux" ? ["--user", String(process.getuid()) + ":" + String(process.getgid())] : []), "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--mount", "type=bind,src=" + scratch + ",dst=/export", "--env", "PGPASSWORD", "--env", "PGSSLMODE", "--env", "PGOPTIONS=-c default_transaction_read_only=on -c statement_timeout=120000", diff --git a/scripts/docker-migration-export.mjs b/scripts/docker-migration-export.mjs index 6596d51..bbc52cf 100644 --- a/scripts/docker-migration-export.mjs +++ b/scripts/docker-migration-export.mjs @@ -154,8 +154,12 @@ export async function exportDockerMigration({ appId, databaseId, destination, co } finally { try { if (restart) { - await run(["start", selected.Id]); - if (!(await inspect(selected.Id, run)).State.Running) throw new Error("The backup operation ended, but the original Docker application did not restart. Start that selected container in Docker Desktop; its data is preserved."); + try { + await run(["start", selected.Id]); + if (!(await inspect(selected.Id, run)).State.Running) throw new Error("not running"); + } catch { + throw new Error("The backup operation ended, but the original Docker application " + summary(selected).name + " did not restart. Start that selected container in Docker; its data is preserved."); + } } } finally { await rm(scratch, { recursive: true, force: true }); From 13b8a6fd2cca3a6b224502d11814f4238a963f8b Mon Sep 17 00:00:00 2001 From: prsmith777 Date: Wed, 21 Oct 2026 18:16:45 -0600 Subject: [PATCH 04/12] Prepare Docker migration release and reproduce Original schema initialization --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- CHANGELOG.md | 16 ++++++++++++++++ docs/INSTALL.md | 2 +- docs/UPDATES.md | 6 +++--- lib/help-manual.mjs | 2 +- lib/release-info.mjs | 8 ++++++-- package.json | 2 +- scripts/test-docker-migration-export.mjs | 8 +++++++- scripts/test-windows-setup-install.mjs | 2 +- test/release-info.test.mjs | 8 ++++---- 10 files changed, 41 insertions(+), 15 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 6fca24d..ee1ea25 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -16,7 +16,7 @@ body: attributes: label: ALPR Community version description: Find this under Settings → Release. Include the source revision there when reporting an installation or update problem. - placeholder: v0.1.55 + placeholder: v0.1.56 validations: required: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 7422118..e2822b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.1.56 — Docker migration export (unreleased) + +- Export the original Docker ALPR database from Windows Setup before creating a + native installation. Select the source application/database explicitly and + review the recognized source profile, PostgreSQL version and plate-read count. +- Export Docker sources on Linux with `./alpr-community migrate export-docker` + and copy the same verified portable folder to native Windows. +- Preserve source database volumes and ports, use read-only database sessions, + verify logical dump counts plus images/settings/authentication, and restore + the selected source application's prior running/stopped state. +- Refuse conflicting database settings, changed source data, overwritten backups + and populated import targets. Imports do not merge old and new histories. +- Update User Guide 3.24, migration/install instructions and the product roadmap + to distinguish native backups from Docker exports and preserve newer records. + + ## 0.1.55 — 2026-10-07 - Change the private native Windows PostgreSQL port later under Settings → diff --git a/docs/INSTALL.md b/docs/INSTALL.md index d09728d..45a6baf 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -64,7 +64,7 @@ Clone the canonical repository and detach at the exact stable release tag: ```bash git clone https://github.com/prsmith777/ALPR-Database-Community.git cd ALPR-Database-Community -git checkout --detach v0.1.55 +git checkout --detach v0.1.56 ./alpr-community install ``` diff --git a/docs/UPDATES.md b/docs/UPDATES.md index a9a89ba..90fef93 100644 --- a/docs/UPDATES.md +++ b/docs/UPDATES.md @@ -47,7 +47,7 @@ shown on the GitHub Releases page, rather than deploying moving `main`: ```bash git clone https://github.com/prsmith777/ALPR-Database-Community.git cd ALPR-Database-Community -git checkout --detach v0.1.55 +git checkout --detach v0.1.56 ./alpr-community install ``` @@ -88,7 +88,7 @@ alpr_native_update() ( set -Eeuo pipefail download_dir="$(mktemp -d /tmp/alpr-maintenance-download.XXXXXX)" trap 'rm -rf -- "$download_dir"' EXIT - asset_base="https://github.com/prsmith777/ALPR-Database-Community/releases/download/v0.1.55" + asset_base="https://github.com/prsmith777/ALPR-Database-Community/releases/download/v0.1.56" curl -fLsS --retry 3 "$asset_base/community-maintenance.sh" -o "$download_dir/community-maintenance.sh" curl -fLsS --retry 3 "$asset_base/community-maintenance.sh.sha256" -o "$download_dir/community-maintenance.sh.sha256" (cd "$download_dir" && sha256sum --check --strict community-maintenance.sh.sha256) @@ -99,7 +99,7 @@ unset -f alpr_native_update ``` The launcher verifies the canonical repository and exact released tag, loads the -fixed tools outside your checkout, and asks for **INSTALL v0.1.55**. It uses the +fixed tools outside your checkout, and asks for **INSTALL v0.1.56**. It uses the same guarded backup/install workflow and does not reset or reinstall your system. It temporarily stops only this installation's matching per-user systemd update agent, then restarts it with the installed code. Keep the terminal open and do diff --git a/lib/help-manual.mjs b/lib/help-manual.mjs index 01190ff..6e3c223 100644 --- a/lib/help-manual.mjs +++ b/lib/help-manual.mjs @@ -13,7 +13,7 @@ export const HELP_MANUAL = Object.freeze({ shortTitle: "User Guide", description: "A practical guide to using, configuring, and safely administering the portable Community application.", manualVersion: "3.24", - updatedAt: "October 7, 2026", + updatedAt: "October 21, 2026", coverageBaseline: "Published clean-history Community release with portable configuration and no private deployment controls.", filename: "ALPR-Database-Community-User-Guide.pdf", sections: [ diff --git a/lib/release-info.mjs b/lib/release-info.mjs index 80951c9..23ec2c3 100644 --- a/lib/release-info.mjs +++ b/lib/release-info.mjs @@ -4,9 +4,13 @@ import builtReleaseMetadata from "./built-release-metadata.mjs"; import { HELP_MANUAL } from "./help-manual.mjs"; export const CURRENT_RELEASE_NOTES = Object.freeze({ - title: "Windows PostgreSQL port control", - publishedAt: "2026-10-07", + title: "Docker-to-Windows database migration", + publishedAt: "2026-10-21", items: Object.freeze([ + "Export the original Docker database before native Windows installation, with explicit source selection and plate-read count review.", + "Move supported Docker ALPR installations from Linux or Windows to native Windows using a verified database/image/settings/authentication bundle.", + "Restore the selected source application state after export and refuse changed sources, ambiguous database settings or nonempty import targets.", + "Update User Guide 3.24 and the migration roadmap with both source-platform procedures and preservation of existing new records.", "Keep Windows updates running through brief status-file sharing locks; progress reporting failures no longer interrupt maintenance.", "Change the private PostgreSQL port under Settings > General, with conflict checks, verified service restart and automatic configuration recovery.", "Preserve the selected database port through software rollback, browser updates and retained-data reinstall; keep browser and Blue Iris addresses unchanged.", diff --git a/package.json b/package.json index 950b2e5..5151b98 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blueiris-alpr-database", - "version": "0.1.55", + "version": "0.1.56", "private": true, "engines": { "node": ">=24.0.0 <25" diff --git a/scripts/test-docker-migration-export.mjs b/scripts/test-docker-migration-export.mjs index 6542cc9..ffc664b 100644 --- a/scripts/test-docker-migration-export.mjs +++ b/scripts/test-docker-migration-export.mjs @@ -34,7 +34,13 @@ try { for (const file of ["schema.sql", "migrations.sql"]) { const local = path.join(workspace, major + "-" + file); await writeFile(local, await original(file)); await docker(["cp", local, db + ":/tmp/" + file]); - await docker(["exec", db, "psql", "-U", "postgres", "-d", "postgres", "--set", "ON_ERROR_STOP=1", "--single-transaction", "-f", "/tmp/" + file]); + // The pinned Original schema has an ALTER OWNER for devmgmt before its + // migrations create that table. Reproduce its entrypoint semantics, then + // require the complete migrations and supported schema to validate. + const loaded = await docker(["exec", db, "psql", "-U", "postgres", "-d", "postgres", + ...(file === "migrations.sql" ? ["--set", "ON_ERROR_STOP=1", "--single-transaction"] : []), "-f", "/tmp/" + file]); + const unexpected = loaded.stderr.split("\n").filter(line => line.includes("ERROR:") && !line.includes('relation "devmgmt" does not exist')); + assert.deepEqual(unexpected, [], "Pinned Original schema preparation failed unexpectedly."); } await psql(db, "INSERT INTO public.plates(plate_number,occurrence_count) VALUES ('TEST0001',1); INSERT INTO public.plate_reads(plate_number,camera_name,image_path,timestamp) VALUES ('TEST0001','Synthetic camera','images/test.jpg',CURRENT_TIMESTAMP);"); const fixture = path.join(workspace, "files-" + major); diff --git a/scripts/test-windows-setup-install.mjs b/scripts/test-windows-setup-install.mjs index 0fbb9c1..1075462 100644 --- a/scripts/test-windows-setup-install.mjs +++ b/scripts/test-windows-setup-install.mjs @@ -17,7 +17,7 @@ export async function verifyWindowsSetupInstall({compiler,sourceText,failure='no await mkdir(path.join(scratch,'payload'));await writeFile(path.join(scratch,'payload/probe.txt'),'disposable fixture'); await writeFile(path.join(scratch,'Setup-Helpers.ps1'),'# Fixture only');await writeFile(path.join(scratch,'setup-prerequisites.json'),'{}'); await writeFile(path.join(scratch,'Setup.ps1'),`param([string]$Operation,[string]$WorkRoot,[string]$PackageRoot,[string]$ManifestSha256,[switch]$UpdateExisting)\n$ErrorActionPreference='Stop'\nif($Operation -eq 'prepare'){Write-Output 'ALPR_SETUP_PROGRESS:fixture prepared';exit 0}\nif($Operation -eq 'install'){\n if('${failure}' -in @('exit','error-zero')){Write-Output 'ALPR_SETUP_ERROR:Fixture installation refused';if('${failure}' -eq 'exit'){exit 7};exit 0}\n if('${failure}' -ne 'missing-port'){Write-Output 'ALPR_SETUP_PORT:3001'}\n if('${failure}' -ne 'missing-completion'){Write-Output 'ALPR_SETUP_COMPLETE:verified'}\n exit 0\n}\nexit 0\n`); - const code=section(source,'function Powershell: String;','function InitializeSetup:')+section(source,'procedure SetupOutput(','procedure UninstallOutput('); + const code=section(source,'function Powershell: String;','function InitializeSetup:')+section(source,'procedure SetupOutput(','procedure UninstallOutput(').replace(section(source,'procedure DockerBackupClick(Sender: TObject);\nvar','function PrepareToInstall'),''); const fixture=`#define ManifestSha256 "${'0'.repeat(64)}" [Setup] AppId=ALPR-Install-Gate-${path.basename(scratch)} diff --git a/test/release-info.test.mjs b/test/release-info.test.mjs index 8bb69b8..4d727c5 100644 --- a/test/release-info.test.mjs +++ b/test/release-info.test.mjs @@ -23,18 +23,18 @@ test("release information resolves a commit-pinned deployment image", () => { ALPR_RELEASE_CHANNEL: "staging", }); - assert.equal(release.version, "0.1.55"); + assert.equal(release.version, "0.1.56"); assert.equal(release.manualVersion, "3.24"); - assert.equal(release.manualUpdatedAt, "October 7, 2026"); + assert.equal(release.manualUpdatedAt, "October 21, 2026"); assert.equal(release.gitSha, "8cd2fa8"); assert.equal(release.channel, "staging"); assert.equal(release.source, "commit-pinned image"); assert.equal(release.readOnly, true); assert.equal( release.notes.title, - "Windows PostgreSQL port control" + "Docker-to-Windows database migration" ); - assert.equal(release.notes.publishedAt, "2026-10-07"); + assert.equal(release.notes.publishedAt, "2026-10-21"); assert.ok(release.notes.items.length >= 3); const notes = release.notes.items.join(" "); assert.match(notes, /LPR camera/i); From 9663dec263aa706dba9940a13dd399baefb0c523 Mon Sep 17 00:00:00 2001 From: prsmith777 Date: Wed, 21 Oct 2026 18:19:12 -0600 Subject: [PATCH 05/12] Verify full installer handlers and wait for final PostgreSQL readiness --- scripts/test-docker-migration-export.mjs | 4 ++-- scripts/test-windows-setup-compile.mjs | 25 +++++++++++++++++++++++ scripts/windows/CommunitySetup.iss | 4 +++- scripts/windows/ExportDockerMigration.ps1 | 3 +++ test/windows-setup.test.mjs | 7 +++++++ 5 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 scripts/test-windows-setup-compile.mjs diff --git a/scripts/test-docker-migration-export.mjs b/scripts/test-docker-migration-export.mjs index ffc664b..13882b4 100644 --- a/scripts/test-docker-migration-export.mjs +++ b/scripts/test-docker-migration-export.mjs @@ -16,7 +16,7 @@ async function original(file) { return response.text(); } async function psql(id, sql) { - return (await docker(["exec", id, "psql", "-U", "postgres", "-d", "postgres", "-At", "--set", "ON_ERROR_STOP=1", "-c", sql])).stdout.trim(); + return (await docker(["exec", id, "psql", "-h", "127.0.0.1", "-U", "postgres", "-d", "postgres", "-At", "--set", "ON_ERROR_STOP=1", "-c", sql])).stdout.trim(); } try { for (const major of [13, 17]) { @@ -37,7 +37,7 @@ try { // The pinned Original schema has an ALTER OWNER for devmgmt before its // migrations create that table. Reproduce its entrypoint semantics, then // require the complete migrations and supported schema to validate. - const loaded = await docker(["exec", db, "psql", "-U", "postgres", "-d", "postgres", + const loaded = await docker(["exec", db, "psql", "-h", "127.0.0.1", "-U", "postgres", "-d", "postgres", ...(file === "migrations.sql" ? ["--set", "ON_ERROR_STOP=1", "--single-transaction"] : []), "-f", "/tmp/" + file]); const unexpected = loaded.stderr.split("\n").filter(line => line.includes("ERROR:") && !line.includes('relation "devmgmt" does not exist')); assert.deepEqual(unexpected, [], "Pinned Original schema preparation failed unexpectedly."); diff --git a/scripts/test-windows-setup-compile.mjs b/scripts/test-windows-setup-compile.mjs new file mode 100644 index 0000000..3da2dd3 --- /dev/null +++ b/scripts/test-windows-setup-compile.mjs @@ -0,0 +1,25 @@ +import { mkdtemp, mkdir, cp, rm } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import os from "node:os"; +const root = path.resolve(import.meta.dirname, ".."); +// Compile every production event handler; never launch the resulting installer. +export async function verifyWindowsSetupCompilation({ compiler }) { + const scratch = await mkdtemp(path.join(os.tmpdir(), "alpr-full-compile-")); + try { + const payload = path.join(scratch, "payload"); + await mkdir(path.join(payload, "app/public"), { recursive: true }); + await cp(path.join(root, "LICENSE"), path.join(payload, "LICENSE")); + await cp(path.join(root, "public/license-plate.ico"), path.join(payload, "app/public/license-plate.ico")); + const result = spawnSync(path.resolve(compiler), ["/Q", "/DPackageRoot=" + payload, + "/DManifestSha256=" + "0".repeat(64), "/DPackageVersion=0.0.0", + "/DOutputRoot=" + scratch, "/DOutputName=compile-only", + path.join(root, "scripts/windows/CommunitySetup.iss")], + { cwd: root, encoding: "utf8", windowsHide: true, timeout: 30_000 }); + if (result.error || result.status !== 0) throw new Error("Full installer compilation failed: " + (result.error?.message || result.stdout + result.stderr)); + return { verified: true, executed: false }; + } finally { + if (path.dirname(scratch) !== path.resolve(os.tmpdir())) throw new Error("Unexpected compilation fixture path."); + await rm(scratch, { recursive: true, force: true }); + } +} diff --git a/scripts/windows/CommunitySetup.iss b/scripts/windows/CommunitySetup.iss index 392925a..302a0b5 100644 --- a/scripts/windows/CommunitySetup.iss +++ b/scripts/windows/CommunitySetup.iss @@ -375,6 +375,7 @@ procedure DockerBackupClick(Sender: TObject); var ResultCode: Integer; ResultFile, BackupPath, Parameters: String; + BackupBytes: AnsiString; begin #ifndef StartupProbe DockerBackupButton.Enabled := False; @@ -392,7 +393,8 @@ begin ExpandConstant('{tmp}\payload\host\ExportDockerMigration.ps1') + '" -PackageRoot "' + ExpandConstant('{tmp}\payload') + '" -ResultFile "' + ResultFile + '"'; if Exec(Powershell, Parameters, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and - (ResultCode = 0) and LoadStringFromFile(ResultFile, BackupPath) then begin + (ResultCode = 0) and LoadStringFromFile(ResultFile, BackupBytes) then begin + BackupPath := UTF8Decode(BackupBytes); MigrationPage.Values[0] := Trim(BackupPath); SourceStoppedCheck.Checked := False; MsgBox('Your Docker backup is selected. Pause ingestion on the original source again before continuing. Import requires an empty native target and cannot merge an existing Community database.', mbInformation, MB_OK); diff --git a/scripts/windows/ExportDockerMigration.ps1 b/scripts/windows/ExportDockerMigration.ps1 index 82be0dd..c94440f 100644 --- a/scripts/windows/ExportDockerMigration.ps1 +++ b/scripts/windows/ExportDockerMigration.ps1 @@ -56,6 +56,9 @@ try { $button = New-Object Windows.Forms.Button; $button.Text = 'Review source'; $button.SetBounds(410,315,140,35); $button.DialogResult = 'OK' $cancel = New-Object Windows.Forms.Button; $cancel.Text = 'Cancel'; $cancel.SetBounds(565,315,130,35); $cancel.DialogResult = 'Cancel' $form.Controls.AddRange(@($appLabel,$appBox,$dbLabel,$dbBox,$notice,$button,$cancel)) + $button.Enabled = $false + $appBox.Add_SelectedIndexChanged({ $button.Enabled = $appBox.SelectedIndex -ge 0 -and $dbBox.SelectedIndex -ge 0 }) + $dbBox.Add_SelectedIndexChanged({ $button.Enabled = $appBox.SelectedIndex -ge 0 -and $dbBox.SelectedIndex -ge 0 }) $form.AcceptButton = $button; $form.CancelButton = $cancel # Deliberate selection prevents silently choosing an empty Community source. $form.Hide() diff --git a/test/windows-setup.test.mjs b/test/windows-setup.test.mjs index 0067450..7b1ee55 100644 --- a/test/windows-setup.test.mjs +++ b/test/windows-setup.test.mjs @@ -262,3 +262,10 @@ test("setup completion requires the expected selected release and its owned runn await writeFile(controller,"param([string]$Operation)\nWrite-Output '"+JSON.stringify({current,commit,status:"Running",listenerOwned:true})+"'\n"); const result=check();assert.equal(result.status,0,result.stdout+result.stderr);assert.match(result.stdout,/ALPR_SETUP_PORT:3001/); }); + +test("production installer handlers compile, including Docker export and UTF-8 result paths", { + skip: process.platform !== "win32" || !existsSync(compiler), +}, async () => { + const { verifyWindowsSetupCompilation } = await import("../scripts/test-windows-setup-compile.mjs"); + assert.deepEqual(await verifyWindowsSetupCompilation({ compiler }), { verified: true, executed: false }); +}); \ No newline at end of file From 6325875cc3dbcc03c88b21ca6096c57b04fe9f99 Mon Sep 17 00:00:00 2001 From: prsmith777 Date: Wed, 21 Oct 2026 18:26:17 -0600 Subject: [PATCH 06/12] Keep Docker exports bound to the selected database endpoint --- scripts/docker-migration-export.mjs | 7 +++++-- test/docker-migration-export.test.mjs | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/docker-migration-export.mjs b/scripts/docker-migration-export.mjs index bbc52cf..3a66800 100644 --- a/scripts/docker-migration-export.mjs +++ b/scripts/docker-migration-export.mjs @@ -39,11 +39,14 @@ export function validateSourcePair(app, database, config = {}) { const names = [database.Name.replace(/^\//, ""), ...shared.flatMap(name => [database.NetworkSettings.Networks[name].IPAddress, ...(database.NetworkSettings.Networks[name].Aliases || [])])]; if (!shared.length || !names.includes(hostname)) throw new Error("The selected database does not match the application's configured Docker database. No source was stopped."); const password = a.DB_PASSWORD || settings.password || d.POSTGRES_PASSWORD; + const databaseName = a.DB_NAME || settings.name || d.POSTGRES_DB || "postgres"; + const user = a.DB_USER || settings.user || d.POSTGRES_USER || "postgres"; + if (![databaseName, user].every(value => typeof value === "string" && /^[A-Za-z_][A-Za-z0-9_$-]{0,62}$/.test(value))) throw new Error("Use the advanced procedure for nonstandard database names. Docker export does not accept connection strings or endpoint overrides."); if (!password) throw new Error("The source database credential is unavailable. Preserve the source and contact support."); return { ...process.env, ALPR_PG_BIN_DIR: "", ALPR_MIGRATION_DOCKER_SOURCE: database.Id, ALPR_MIGRATION_SOURCE_HOST: "127.0.0.1", - ALPR_MIGRATION_SOURCE_PORT: String(port), ALPR_MIGRATION_SOURCE_DATABASE: a.DB_NAME || settings.name || d.POSTGRES_DB || "postgres", - ALPR_MIGRATION_SOURCE_USER: a.DB_USER || settings.user || d.POSTGRES_USER || "postgres", + ALPR_MIGRATION_SOURCE_PORT: String(port), ALPR_MIGRATION_SOURCE_DATABASE: databaseName, + ALPR_MIGRATION_SOURCE_USER: user, ALPR_MIGRATION_SOURCE_PASSWORD: password, ALPR_MIGRATION_SOURCE_SSLMODE: "disable" }; } async function inspect(id, run) { diff --git a/test/docker-migration-export.test.mjs b/test/docker-migration-export.test.mjs index 4deebc5..13d067f 100644 --- a/test/docker-migration-export.test.mjs +++ b/test/docker-migration-export.test.mjs @@ -31,6 +31,8 @@ test("source pairing follows the selected application's configured network alias assert.throws(() => validateSourcePair(app, { ...database, Id: appId }), /separate/); assert.throws(() => validateSourcePair(app, database, { database: { host: "foreign:5432" } }), /does not match/); assert.throws(() => validateSourcePair(app, database, { database: { password: "different-fixture" } }), /settings differ/); + assert.throws(() => validateSourcePair(app, database, { database: { name: "postgresql://other.invalid/postgres" } }), /connection strings/); + assert.throws(() => validateSourcePair(app, database, { database: { name: "host=other.invalid dbname=postgres" } }), /connection strings/); }); test("client transport uses a PostgreSQL 17 client, read-only sessions, no published ports, and no password argument", () => { const scratch = path.resolve(os.tmpdir(), "private export"); From dd6f6a9fd99c769b5bf16a4e8c08086d5310d8d6 Mon Sep 17 00:00:00 2001 From: prsmith777 Date: Wed, 21 Oct 2026 18:35:44 -0600 Subject: [PATCH 07/12] Require an internal capability for Docker migration transport --- scripts/docker-migration-client.mjs | 3 +++ scripts/docker-migration-export.mjs | 4 ++-- scripts/postgres-major-migration.mjs | 11 ++++++----- test/docker-migration-export.test.mjs | 14 ++++++++++++++ 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/scripts/docker-migration-client.mjs b/scripts/docker-migration-client.mjs index 296c45f..eff0b15 100644 --- a/scripts/docker-migration-client.mjs +++ b/scripts/docker-migration-client.mjs @@ -4,6 +4,9 @@ import { existsSync } from "node:fs"; import { randomUUID } from "node:crypto"; export const POSTGRES_CLIENT_IMAGE = "postgres:17.10"; +// An inherited string environment variable must never redirect a protected +// native backup. Only the source exporter can attach this in-process capability. +export const DOCKER_POSTGRES_CONTEXT = Symbol("Docker migration source"); export function dockerCommand() { const desktop = path.join(process.env.ProgramFiles || "C:\\Program Files", "Docker", "Docker", "resources", "bin", "docker.exe"); return process.platform === "win32" && existsSync(desktop) ? desktop : "docker"; diff --git a/scripts/docker-migration-export.mjs b/scripts/docker-migration-export.mjs index 3a66800..0e11ba2 100644 --- a/scripts/docker-migration-export.mjs +++ b/scripts/docker-migration-export.mjs @@ -3,7 +3,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; import { randomUUID } from "node:crypto"; -import { docker, POSTGRES_CLIENT_IMAGE } from "./docker-migration-client.mjs"; +import { docker, POSTGRES_CLIENT_IMAGE, DOCKER_POSTGRES_CONTEXT } from "./docker-migration-client.mjs"; import { postgresMajorMigrationInternals as pg, runPostgresMigrationCommand } from "./postgres-major-migration.mjs"; import { createMigrationBundle, verifyMigrationBundle } from "./community-migration-bundle.mjs"; import { fileInventory, assertRealDirectory } from "./windows-deployment.mjs"; @@ -43,7 +43,7 @@ export function validateSourcePair(app, database, config = {}) { const user = a.DB_USER || settings.user || d.POSTGRES_USER || "postgres"; if (![databaseName, user].every(value => typeof value === "string" && /^[A-Za-z_][A-Za-z0-9_$-]{0,62}$/.test(value))) throw new Error("Use the advanced procedure for nonstandard database names. Docker export does not accept connection strings or endpoint overrides."); if (!password) throw new Error("The source database credential is unavailable. Preserve the source and contact support."); - return { ...process.env, ALPR_PG_BIN_DIR: "", + return { ...process.env, [DOCKER_POSTGRES_CONTEXT]: true, ALPR_PG_BIN_DIR: "", ALPR_MIGRATION_DOCKER_SOURCE: database.Id, ALPR_MIGRATION_SOURCE_HOST: "127.0.0.1", ALPR_MIGRATION_SOURCE_PORT: String(port), ALPR_MIGRATION_SOURCE_DATABASE: databaseName, ALPR_MIGRATION_SOURCE_USER: user, diff --git a/scripts/postgres-major-migration.mjs b/scripts/postgres-major-migration.mjs index c072d42..54b1ea3 100644 --- a/scripts/postgres-major-migration.mjs +++ b/scripts/postgres-major-migration.mjs @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { dockerPostgres } from "./docker-migration-client.mjs"; +import { dockerPostgres, DOCKER_POSTGRES_CONTEXT } from "./docker-migration-client.mjs"; import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; import { access, chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises"; @@ -78,8 +78,9 @@ const LEGACY_V019_REQUIRED_PRIMARY_KEYS = Object.freeze({ tags: Object.freeze(["id"]), }); -function required(environment, name) { - const value = String(environment[name] || "").trim(); +function required(environment, name, { trim = true } = {}) { + const supplied = String(environment[name] || ""); + const value = trim ? supplied.trim() : supplied; if (!value) throw new Error(`${name} is required`); return value; } @@ -98,7 +99,7 @@ function readEndpoint(environment, prefix) { port: parsePort(environment[`${prefix}_PORT`] || "5432", `${prefix}_PORT`), database: required(environment, `${prefix}_DATABASE`), user: required(environment, `${prefix}_USER`), - password: required(environment, `${prefix}_PASSWORD`), + password: required(environment, `${prefix}_PASSWORD`, { trim: false }), sslMode: String(environment[`${prefix}_SSLMODE`] || "prefer").trim().toLowerCase(), }; } @@ -154,7 +155,7 @@ function clientEnvironment(endpoint, environment) { } function runProcess(program, args, { environment = process.env, inherit = false } = {}) { - if (environment.ALPR_MIGRATION_DOCKER_SOURCE) return dockerPostgres(program, args, environment); + if (environment[DOCKER_POSTGRES_CONTEXT] === true) return dockerPostgres(program, args, environment); return new Promise((resolvePromise, reject) => { const child = spawn(program, args, { env: environment, diff --git a/test/docker-migration-export.test.mjs b/test/docker-migration-export.test.mjs index 13d067f..fcf40ef 100644 --- a/test/docker-migration-export.test.mjs +++ b/test/docker-migration-export.test.mjs @@ -75,3 +75,17 @@ test("Windows exporter script parses with inbox PowerShell and installer exposes assert.equal(result.status, 0, result.stderr); } }); + +test("inherited Docker flags cannot redirect a native PostgreSQL process", async () => { + const { postgresMajorMigrationInternals: pg } = await import("../scripts/postgres-major-migration.mjs"); + const result = await pg.runProcess(process.execPath, ["-e", "process.stdout.write('native')"], { + environment: { ...process.env, ALPR_MIGRATION_DOCKER_SOURCE: dbId, ALPR_MIGRATION_DOCKER_SCRATCH: os.tmpdir() }, + }); + assert.equal(result.stdout, "native"); +}); +test("database password whitespace is preserved across logical migration", async () => { + const { postgresMajorMigrationInternals: pg } = await import("../scripts/postgres-major-migration.mjs"); + const endpoint = pg.readEndpoint({ ALPR_MIGRATION_SOURCE_HOST: "127.0.0.1", ALPR_MIGRATION_SOURCE_DATABASE: "postgres", + ALPR_MIGRATION_SOURCE_USER: "postgres", ALPR_MIGRATION_SOURCE_PASSWORD: " fixture secret " }, "ALPR_MIGRATION_SOURCE"); + assert.equal(endpoint.password, " fixture secret "); +}); \ No newline at end of file From 1f5cd8c8da62e4213d577b0c3f72619dbe76f930 Mon Sep 17 00:00:00 2001 From: prsmith777 Date: Wed, 21 Oct 2026 18:38:22 -0600 Subject: [PATCH 08/12] Keep migration ingestion paused through target verification --- docs/DOCKER_TO_WINDOWS_MIGRATION.md | 17 +++++++++++------ lib/help-manual.mjs | 2 +- scripts/docker-migration-interactive.mjs | 4 ++-- scripts/windows/CommunitySetup.iss | 2 +- scripts/windows/ExportDockerMigration.ps1 | 6 +++--- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/DOCKER_TO_WINDOWS_MIGRATION.md b/docs/DOCKER_TO_WINDOWS_MIGRATION.md index d749aed..a8740f7 100644 --- a/docs/DOCKER_TO_WINDOWS_MIGRATION.md +++ b/docs/DOCKER_TO_WINDOWS_MIGRATION.md @@ -37,7 +37,9 @@ Docker database. and **plate-read count**. If the count is zero and you expected historical records, cancel and select the correct source. Unknown or incomplete schemas stop the export. -5. Pause other jobs or applications that write to this database or image storage. +5. Pause Blue Iris ingestion and other jobs or applications that write to this + database or image storage. Keep ingestion paused until the Windows import has + been verified. Confirm the displayed source and choose a private local backup location. The tool stops only the selected Docker ALPR application, makes and verifies its logical dump, images, settings and authentication, then restarts it if it @@ -46,9 +48,10 @@ Docker database. folder automatically. Keep the entire folder, including `migration-backup.json`. A failure preserves source data and any partial export; keep the diagnostic folder. A partial export is not a usable migration backup. -7. Pause ingestion on the old source **again** before importing. The backup tool - restarts the original app; the confirmation box in Setup is deliberately reset. - Continue with the import procedure below. +7. Keep ingestion on the old source paused through import and verification. + The exporter restarts the app but does not control Blue Iris senders; Setup's + confirmation is deliberately reset. If ingestion resumed after export, create + a fresh export for cutover so later reads are included. Continue below. The tool uses PostgreSQL 17 client containers and may download the matching official image. It connects within the selected database container's network @@ -76,13 +79,15 @@ identifies itself as a native Windows backup. 3. Select the original application and database from the numbered lists. Enter an absolute path to a private local backup directory. Review the displayed source type and plate-read count. -4. Pause every other database/image writer, then type the confirmation shown by +4. Pause Blue Iris ingestion and every other database/image writer, then type + the confirmation shown by the exporter. The selected source application is briefly stopped and returned to its original running/stopped state. Wait for the verified backup path. 5. Copy the **entire completed backup folder** from Linux to Windows using your normal protected file transfer. Include hidden files and all manifests. Never copy `/var/lib/postgresql/data` into the Windows database directory. -6. Pause old-source ingestion again before import. In Windows Setup select +6. Keep old-source ingestion paused until import is verified. If it resumed, + export again before cutover to include later reads. In Windows Setup select **Move an existing ALPR database and images**, browse to the copied folder containing `migration-backup.json`, and continue below. diff --git a/lib/help-manual.mjs b/lib/help-manual.mjs index 6e3c223..7ff2e59 100644 --- a/lib/help-manual.mjs +++ b/lib/help-manual.mjs @@ -104,7 +104,7 @@ export const HELP_MANUAL = Object.freeze({ "items": [ "For Docker Desktop on Windows, run Setup, choose Move an existing ALPR database and images, and select Back up original Docker ALPR. Select the original Docker app and database, review their source version and plate-read count, and confirm the export. This works before installing Community. The native Windows backup shortcut is a different tool.", "For Docker on Linux, run ./alpr-community migrate export-docker from a prepared current Community checkout on the source host. Review the original source and counts, then copy the entire completed backup folder to Windows. For non-Docker Linux databases use the advanced portable backup procedure. A SQL dump alone is incomplete.", - "Pause other database/image writers before exporting. The exporter briefly stops only the selected Docker app and returns it to its original running/stopped state. Pause source ingestion again before import. If native Community already contains records, preserve its separate backup and plan a clean target; import never merges histories.", + "Pause Blue Iris ingestion and other database/image writers before exporting and keep ingestion paused until Windows import is verified. The exporter returns the selected Docker app to its original running/stopped state but does not control Blue Iris. If ingestion resumed, export again for cutover to include later reads. If native Community already contains records, preserve its separate backup and plan a clean target; import never merges histories.", "Native Windows creates its own PostgreSQL 17 cluster under C:\\ProgramData\\ALPR Community\\management\\postgres, normally at 127.0.0.1:5433. A logical backup import does not modify the source Docker PostgreSQL 13 database or its port mappings. Databases are independent after the import; new records are not synchronized.", "Copy the completed folder to the new computer. Pause source ingestion, run Setup, choose Move an existing ALPR database and images, and select the folder containing migration-backup.json. This requires an empty destination.", "Choose the destination setup administrator password. Named accounts and ingestion keys are retained; the compatibility administrator uses the newly chosen password and old browser sessions are invalidated.", diff --git a/scripts/docker-migration-interactive.mjs b/scripts/docker-migration-interactive.mjs index 483cb91..518e12f 100644 --- a/scripts/docker-migration-interactive.mjs +++ b/scripts/docker-migration-interactive.mjs @@ -24,12 +24,12 @@ try { const preview = await previewDockerExport({ appId: app.id, databaseId: database.id, workParent: parent }); console.log(preview.sourceLabel + ", PostgreSQL " + preview.serverMajor + ", plate reads: " + preview.plateReads); if (preview.plateReads === "0") console.log("WARNING: this source is empty. Cancel if you expected historical records."); - console.log("Pause every other database writer. The selected original Docker app will stop briefly and restart afterward."); + console.log("Pause Blue Iris ingestion and every other database/image writer. Keep ingestion paused until Windows import is verified. The selected original Docker app restarts afterward; this does not resume Blue Iris senders."); if (await input.question("Type EXPORT ORIGINAL DOCKER DATABASE to confirm: ") !== "EXPORT ORIGINAL DOCKER DATABASE") throw new Error("Cancelled without stopping the source."); const destination = path.join(parent, "Docker-ALPR-" + randomUUID()); const result = await exportDockerMigration({ appId: app.id, databaseId: database.id, destination, confirmation: preview.confirmation, expected: preview, writersPaused: true }); - console.log("Verified backup: " + result.destination + os.EOL + "Copy the entire folder to Windows. Pause source ingestion again before import. The importer requires an empty target and does not merge existing records."); + console.log("Verified backup: " + result.destination + os.EOL + "Copy the entire folder to Windows. Keep source ingestion paused until import is verified. If it resumed, export again before cutover to include later reads. The importer requires an empty target and does not merge existing records."); } catch (error) { console.error(error.message); process.exitCode = 1; } finally { input.close(); } diff --git a/scripts/windows/CommunitySetup.iss b/scripts/windows/CommunitySetup.iss index 302a0b5..888034d 100644 --- a/scripts/windows/CommunitySetup.iss +++ b/scripts/windows/CommunitySetup.iss @@ -397,7 +397,7 @@ begin BackupPath := UTF8Decode(BackupBytes); MigrationPage.Values[0] := Trim(BackupPath); SourceStoppedCheck.Checked := False; - MsgBox('Your Docker backup is selected. Pause ingestion on the original source again before continuing. Import requires an empty native target and cannot merge an existing Community database.', mbInformation, MB_OK); + MsgBox('Your Docker backup is selected. Keep source ingestion paused until import is verified. If it resumed, create a fresh export for cutover. Import requires an empty native target and cannot merge an existing Community database.', mbInformation, MB_OK); end; DeleteFile(ResultFile); finally diff --git a/scripts/windows/ExportDockerMigration.ps1 b/scripts/windows/ExportDockerMigration.ps1 index c94440f..82fc6f4 100644 --- a/scripts/windows/ExportDockerMigration.ps1 +++ b/scripts/windows/ExportDockerMigration.ps1 @@ -52,7 +52,7 @@ try { $dbBox = New-Object Windows.Forms.ComboBox; $dbBox.DropDownStyle = 'DropDownList'; $dbBox.SetBounds(20,175,680,28) foreach ($item in $databases) { [void]$dbBox.Items.Add($item.name + ' | ' + $item.image) } $notice = New-Object Windows.Forms.Label; $notice.SetBounds(20,220,680,65) - $notice.Text = 'The next step shows the source version and record count. Export pauses only the selected Docker app and restarts it afterward. Other database writers must be paused. Existing native Community data is never replaced or merged.' + $notice.Text = 'The next step shows the source version and record count. Export pauses only the selected Docker app and restarts it afterward. Pause Blue Iris ingestion and other writers before export; keep ingestion paused until Windows import is verified. Existing native data is never replaced or merged.' $button = New-Object Windows.Forms.Button; $button.Text = 'Review source'; $button.SetBounds(410,315,140,35); $button.DialogResult = 'OK' $cancel = New-Object Windows.Forms.Button; $cancel.Text = 'Cancel'; $cancel.SetBounds(565,315,130,35); $cancel.DialogResult = 'Cancel' $form.Controls.AddRange(@($appLabel,$appBox,$dbLabel,$dbBox,$notice,$button,$cancel)) @@ -72,7 +72,7 @@ try { $request = @{appId=$app.id;databaseId=$database.id;workParent=$work} $preview = Invoke-Exporter 'preview' $request $form.Hide() - $message = "Source application: $($app.name)`r`nDatabase container: $($database.name)`r`n$($preview.sourceLabel) on PostgreSQL $($preview.serverMajor)`r`nPlate reads: $($preview.plateReads)`r`n`r`nIs this the original database you want? Pause every OTHER database writer before continuing. ALPR will stop only this Docker application, verify the backup, then restart it." + $message = "Source application: $($app.name)`r`nDatabase container: $($database.name)`r`n$($preview.sourceLabel) on PostgreSQL $($preview.serverMajor)`r`nPlate reads: $($preview.plateReads)`r`n`r`nIs this the original database you want? Pause Blue Iris ingestion and every other database writer before continuing. Keep ingestion paused until the Windows import is verified. ALPR will stop only this Docker application, verify the backup, then restart it; it does not control Blue Iris." if ($preview.plateReads -eq '0') { $message += "`r`n`r`nWARNING: this source has ZERO plate reads. Cancel if you expected historical records." } if ([Windows.Forms.MessageBox]::Show($message,'Confirm original Docker source','OKCancel','Warning') -ne 'OK') { exit 0 } $dialog = New-Object Windows.Forms.FolderBrowserDialog @@ -87,7 +87,7 @@ try { $result = Invoke-Exporter 'export' $request $form.Close(); $form = $null if ($ResultFile) { [IO.File]::WriteAllText($ResultFile, $result.destination, (New-Object Text.UTF8Encoding($false))) } - [void][Windows.Forms.MessageBox]::Show("Verified Docker backup: $($result.destination)`r`nPlate reads: $($result.plateReads)`r`n`r`nKeep the entire folder. For Windows import choose Move an existing ALPR database and images. Pause source ingestion again before importing. Existing native data cannot be merged; back it up before planning a fresh target.",'Docker backup complete','OK','Information') + [void][Windows.Forms.MessageBox]::Show("Verified Docker backup: $($result.destination)`r`nPlate reads: $($result.plateReads)`r`n`r`nKeep the entire folder. For Windows import choose Move an existing ALPR database and images. Keep source ingestion paused until import is verified. If ingestion resumed, create a fresh export before cutover to include later records. Existing native data cannot be merged; back it up before planning a fresh target.",'Docker backup complete','OK','Information') } catch { if ($form) { $form.Close(); $form = $null } [void][Windows.Forms.MessageBox]::Show($_.Exception.Message,'Docker migration backup','OK','Error') From 382530b6f391fd9f43124df79cd9229552e56d76 Mon Sep 17 00:00:00 2001 From: prsmith777 Date: Wed, 21 Oct 2026 18:40:42 -0600 Subject: [PATCH 09/12] Cover current Community Linux sources in the Docker export matrix --- scripts/test-docker-migration-export.mjs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/scripts/test-docker-migration-export.mjs b/scripts/test-docker-migration-export.mjs index 13882b4..34754f4 100644 --- a/scripts/test-docker-migration-export.mjs +++ b/scripts/test-docker-migration-export.mjs @@ -19,7 +19,9 @@ async function psql(id, sql) { return (await docker(["exec", id, "psql", "-h", "127.0.0.1", "-U", "postgres", "-d", "postgres", "-At", "--set", "ON_ERROR_STOP=1", "-c", sql])).stdout.trim(); } try { - for (const major of [13, 17]) { + for (const scenario of [{ major: 13, community: false }, { major: 17, community: false }, { major: 17, community: true }]) { + const { major, community } = scenario; + const scenarioId = String(major) + (community ? "-community" : "-original"); const suffix = randomUUID(), network = "alpr-export-fixture-" + suffix; await docker(["network", "create", network]); networks.push(network); const password = randomUUID(); @@ -42,8 +44,12 @@ try { const unexpected = loaded.stderr.split("\n").filter(line => line.includes("ERROR:") && !line.includes('relation "devmgmt" does not exist')); assert.deepEqual(unexpected, [], "Pinned Original schema preparation failed unexpectedly."); } + if (community) { + await docker(["cp", path.resolve(import.meta.dirname, "../migrations.sql"), db + ":/tmp/community.sql"]); + await docker(["exec", db, "psql", "-h", "127.0.0.1", "-U", "postgres", "-d", "postgres", "--set", "ON_ERROR_STOP=1", "--single-transaction", "-f", "/tmp/community.sql"]); + } await psql(db, "INSERT INTO public.plates(plate_number,occurrence_count) VALUES ('TEST0001',1); INSERT INTO public.plate_reads(plate_number,camera_name,image_path,timestamp) VALUES ('TEST0001','Synthetic camera','images/test.jpg',CURRENT_TIMESTAMP);"); - const fixture = path.join(workspace, "files-" + major); + const fixture = path.join(workspace, "files-" + scenarioId); for (const folder of ["config", "auth", "storage/images"]) await mkdir(path.join(fixture, folder), { recursive: true }); await writeFile(path.join(fixture, "config/settings.yaml"), "general:\n maxRecords: 4321\n"); await writeFile(path.join(fixture, "auth/auth.json"), JSON.stringify({ password: "synthetic-hash", apiKey: "a".repeat(64), sessions: {} })); @@ -53,7 +59,7 @@ try { const name = network + "-" + folder; await docker(["volume", "create", name]); volumes.push(name); mounts.push("--mount", "type=volume,src=" + name + ",dst=/app/" + folder); } - const app = (await docker(["create", "--name", network + "-app", "--network", network, ...mounts, + const app = (await docker(["create", "--init", "--name", network + "-app", "--network", network, ...mounts, "--env", "DB_PASSWORD", "--env", "TZ=America/Denver", "alpine:3.23", "sleep", "3600"], { environment: { ...process.env, DB_PASSWORD: password }, timeout: 300_000 })).stdout.trim(); owned.push(app); @@ -63,8 +69,8 @@ try { assert.ok(listed.containers.some(item => item.id === app && item.application)); assert.ok(!JSON.stringify(listed).includes(password)); const preview = await previewDockerExport({ appId: app, databaseId: db, workParent: workspace }); - assert.equal(preview.sourceProfile, "original-alpr-v0.1.9"); assert.equal(preview.plateReads, "1"); - const request = { appId: app, databaseId: db, destination: path.join(workspace, "backup-" + major), + assert.equal(preview.sourceProfile, community ? "alpr-community-v0.1.20-plus" : "original-alpr-v0.1.9"); assert.equal(preview.plateReads, "1"); + const request = { appId: app, databaseId: db, destination: path.join(workspace, "backup-" + scenarioId), confirmation: preview.confirmation, expected: preview, writersPaused: true }; if (major === 13) { let injected = false; @@ -81,7 +87,7 @@ try { assert.equal(await psql(db, "SELECT count(*) FROM public.plate_reads;"), "1"); assert.equal(JSON.parse(await readFile(path.join(request.destination, "auth/auth.json"), "utf8")).apiKey, "a".repeat(64)); if (process.env.ALPR_DOCKER_TEST_OUTPUT && major === 13) await cp(request.destination, path.resolve(process.env.ALPR_DOCKER_TEST_OUTPUT), { recursive: true, errorOnExist: true, force: false }); - console.log("Docker PostgreSQL " + major + " export passed source attestation, exact row counts, named-volume images/config/auth, restart recovery and unchanged source."); + console.log("Docker " + scenarioId + " export passed source attestation, exact row counts, named-volume images/config/auth, restart recovery and unchanged source."); } } finally { for (const id of owned.reverse()) await docker(["rm", "--force", id]).catch(() => {}); From 554742bffb2238a0e4bc3143dce7fe833db7429b Mon Sep 17 00:00:00 2001 From: prsmith777 Date: Wed, 21 Oct 2026 18:50:17 -0600 Subject: [PATCH 10/12] Keep Docker migration backups outside source storage --- scripts/docker-migration-export.mjs | 48 +++++++++++++++++++++++- scripts/test-docker-migration-export.mjs | 14 +++++-- test/docker-migration-export.test.mjs | 9 ++++- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/scripts/docker-migration-export.mjs b/scripts/docker-migration-export.mjs index 0e11ba2..1044da3 100644 --- a/scripts/docker-migration-export.mjs +++ b/scripts/docker-migration-export.mjs @@ -79,8 +79,52 @@ async function sourceEvidence(env, application) { return { sourceProfile: source.profile.id, sourceLabel: source.profile.label, serverMajor: major, schemaFingerprint: source.schemaFingerprint, tableCounts: counts, plateReads: counts.plate_reads || "0", application }; } -async function prepare({ appId, databaseId, scratch }, run) { +function dockerHostPath(value, platform) { + if (typeof value !== "string" || !value) return null; + let normalized = value.replaceAll("\\", "/"); + if (platform === "win32") { + normalized = normalized.replace(/^\/(?:run\/desktop\/mnt\/host|host_mnt|mnt)\/([a-z])\//i, "$1:/") + .replace(/^\/([a-z])\//i, "$1:/"); + return /^[a-z]:\//i.test(normalized) ? path.win32.resolve(normalized) : null; + } + return path.posix.isAbsolute(normalized) ? path.posix.resolve(normalized) : null; +} +export function assertDockerBackupSeparation(roots, candidates, platform = process.platform) { + const impl = platform === "win32" ? path.win32 : path.posix; + const inside = (parent, child) => { + const relative = impl.relative(parent, child); + return relative === "" || (relative !== ".." && !relative.startsWith(".." + impl.sep) && !impl.isAbsolute(relative)); + }; + for (const source of roots.map(value => dockerHostPath(value, platform)).filter(Boolean)) { + for (const candidate of candidates) { + const target = impl.resolve(candidate); + if (inside(source, target) || inside(target, source)) throw new Error("Choose a backup folder outside the original Docker storage, settings and authentication folders. No source application was stopped."); + } + } +} +async function checkBackupLocation(app, candidates, run) { + const roots = []; + for (const mount of app.Mounts || []) { + if (!["/app/storage", "/app/config", "/app/auth"].includes(mount.Destination)) continue; + if (mount.Source) roots.push(mount.Source); + if (mount.Type === "volume" && mount.Name) { + const records = JSON.parse((await run(["volume", "inspect", mount.Name])).stdout); + const device = records[0]?.Options?.device; + if (!device) continue; + const absolute = dockerHostPath(device, process.platform); + if (absolute) roots.push(absolute); + else if (!device.startsWith(":") && !device.startsWith("//")) { + const working = dockerHostPath(app.Config.Labels?.["com.docker.compose.project.working_dir"], process.platform); + if (working) roots.push((process.platform === "win32" ? path.win32 : path.posix).resolve(working, device)); + } + } + } + assertDockerBackupSeparation(roots, candidates); +} + +async function prepare({ appId, databaseId, scratch, destination }, run) { const app = await inspect(appId, run), database = await inspect(databaseId, run); + await checkBackupLocation(app, [scratch, ...(destination ? [destination] : [])], run); const config = plainConfig(await configuration(run, app, scratch)); const env = { ...validateSourcePair(app, database, config), ALPR_MIGRATION_DOCKER_SCRATCH: scratch }; await run(["image", "inspect", POSTGRES_CLIENT_IMAGE]).catch(() => run(["pull", POSTGRES_CLIENT_IMAGE], { timeout: 10 * 60_000 })); @@ -115,7 +159,7 @@ export async function exportDockerMigration({ appId, databaseId, destination, co const run = options.docker || docker; let selected, restart = false; try { - const result = await prepare({ appId, databaseId, scratch }, run); + const result = await prepare({ appId, databaseId, scratch, destination }, run); selected = result.app; if (result.evidence.sourceProfile !== expected.sourceProfile || result.evidence.schemaFingerprint !== expected.schemaFingerprint) throw new Error("The source schema changed after review. Review it again."); // Full IDs prevent a renamed/replaced container from being stopped by name. diff --git a/scripts/test-docker-migration-export.mjs b/scripts/test-docker-migration-export.mjs index 34754f4..de8cfc4 100644 --- a/scripts/test-docker-migration-export.mjs +++ b/scripts/test-docker-migration-export.mjs @@ -55,15 +55,23 @@ try { await writeFile(path.join(fixture, "auth/auth.json"), JSON.stringify({ password: "synthetic-hash", apiKey: "a".repeat(64), sessions: {} })); await writeFile(path.join(fixture, "storage/images/test.jpg"), "synthetic image"); const mounts = []; + const bindMounted = major === 17 && !community; for (const folder of ["config", "auth", "storage"]) { - const name = network + "-" + folder; await docker(["volume", "create", name]); volumes.push(name); - mounts.push("--mount", "type=volume,src=" + name + ",dst=/app/" + folder); + if (bindMounted) mounts.push("--mount", "type=bind,src=" + path.join(fixture, folder) + ",dst=/app/" + folder); + else { + const name = network + "-" + folder; await docker(["volume", "create", name]); volumes.push(name); + mounts.push("--mount", "type=volume,src=" + name + ",dst=/app/" + folder); + } } const app = (await docker(["create", "--init", "--name", network + "-app", "--network", network, ...mounts, "--env", "DB_PASSWORD", "--env", "TZ=America/Denver", "alpine:3.23", "sleep", "3600"], { environment: { ...process.env, DB_PASSWORD: password }, timeout: 300_000 })).stdout.trim(); owned.push(app); - for (const folder of ["config", "auth", "storage"]) await docker(["cp", path.join(fixture, folder) + path.sep + ".", app + ":/app/" + folder]); + if (!bindMounted) for (const folder of ["config", "auth", "storage"]) await docker(["cp", path.join(fixture, folder) + path.sep + ".", app + ":/app/" + folder]); + if (bindMounted) { + await assert.rejects(previewDockerExport({ appId: app, databaseId: db, workParent: path.join(fixture, "storage") }), /outside the original Docker/); + assert.equal(await readFile(path.join(fixture, "storage/images/test.jpg"), "utf8"), "synthetic image"); + } if (major === 13) await docker(["start", app]); const listed = await listDockerSources(); assert.ok(listed.containers.some(item => item.id === app && item.application)); diff --git a/test/docker-migration-export.test.mjs b/test/docker-migration-export.test.mjs index fcf40ef..092004a 100644 --- a/test/docker-migration-export.test.mjs +++ b/test/docker-migration-export.test.mjs @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import path from "node:path"; import { mkdtemp, rm, readFile } from "node:fs/promises"; import os from "node:os"; -import { sourceCandidates, validateSourcePair, exportDockerMigration } from "../scripts/docker-migration-export.mjs"; +import { sourceCandidates, validateSourcePair, exportDockerMigration, assertDockerBackupSeparation } from "../scripts/docker-migration-export.mjs"; import { dockerPostgresArguments, POSTGRES_CLIENT_IMAGE } from "../scripts/docker-migration-client.mjs"; const appId = "a".repeat(64), dbId = "b".repeat(64); function fixtures() { @@ -88,4 +88,11 @@ test("database password whitespace is preserved across logical migration", async const endpoint = pg.readEndpoint({ ALPR_MIGRATION_SOURCE_HOST: "127.0.0.1", ALPR_MIGRATION_SOURCE_DATABASE: "postgres", ALPR_MIGRATION_SOURCE_USER: "postgres", ALPR_MIGRATION_SOURCE_PASSWORD: " fixture secret " }, "ALPR_MIGRATION_SOURCE"); assert.equal(endpoint.password, " fixture secret "); +}); +test("backup paths cannot intersect original bind storage on Linux or Docker Desktop", () => { + assert.throws(() => assertDockerBackupSeparation(["/srv/alpr/storage"], ["/srv/alpr/storage/backup"], "linux"), /outside/); + assert.throws(() => assertDockerBackupSeparation(["C:/ALPR/storage"], ["C:/ALPR/storage/backup"], "win32"), /outside/); + assert.throws(() => assertDockerBackupSeparation(["/run/desktop/mnt/host/c/ALPR/storage"], ["C:/ALPR/storage/backup"], "win32"), /outside/); + assert.throws(() => assertDockerBackupSeparation(["/host_mnt/c/ALPR/auth"], ["C:/ALPR/auth/backup"], "win32"), /outside/); + assert.doesNotThrow(() => assertDockerBackupSeparation(["/srv/alpr/storage"], ["/srv/alpr/backup"], "linux")); }); \ No newline at end of file From 10c3a35dabae433cb738cc2d515e93953ba3adb8 Mon Sep 17 00:00:00 2001 From: prsmith777 Date: Wed, 21 Oct 2026 19:08:12 -0600 Subject: [PATCH 11/12] Finalize Community 0.1.56 release documentation --- CHANGELOG.md | 2 +- docs/COMMUNITY_PRODUCT_ROADMAP.md | 18 +++++++++++------- docs/DOCKER_TO_WINDOWS_MIGRATION.md | 5 ++--- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2822b7..5fbb402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.1.56 — Docker migration export (unreleased) +## 0.1.56 — 2026-10-21 - Export the original Docker ALPR database from Windows Setup before creating a native installation. Select the source application/database explicitly and diff --git a/docs/COMMUNITY_PRODUCT_ROADMAP.md b/docs/COMMUNITY_PRODUCT_ROADMAP.md index 8fc3af2..5060209 100644 --- a/docs/COMMUNITY_PRODUCT_ROADMAP.md +++ b/docs/COMMUNITY_PRODUCT_ROADMAP.md @@ -2,17 +2,21 @@ Last reviewed: October 21, 2026 -## Docker-to-Windows export implementation +## Docker-to-Windows migration export -The next release adds source-selecting export for Original/Community Docker +Community v0.1.56 adds source-selecting export for Original/Community Docker installations on Windows and Linux. Windows Setup exposes export before native -installation; Linux uses ./alpr-community migrate export-docker. Both produce the -same verified logical backup, with source profile/record-count review, image and -authentication preservation, and restoration of the selected app's original +installation; Linux uses `./alpr-community migrate export-docker`. Both produce +the same verified logical backup, with source profile/record-count review, image +and authentication preservation, and restoration of the selected app's original running state. Native imports continue to require an empty target and do not merge histories. See [migration procedure](DOCKER_TO_WINDOWS_MIGRATION.md). -Acceptance and release status must be checked in the associated pull request; -this section does not claim the change is already published. + +Automated validation covers Original PostgreSQL 13/17 and Community PostgreSQL 17 +Docker exports, named volumes and bind mounts, and a Linux-created backup restored +into native Windows PostgreSQL. The Windows graphical exporter compiles, but a +real Windows Docker Desktop source export remains a field-test limitation. +GitHub Releases records publication and the exact release assets. ## Native Windows is available diff --git a/docs/DOCKER_TO_WINDOWS_MIGRATION.md b/docs/DOCKER_TO_WINDOWS_MIGRATION.md index a8740f7..5ade56a 100644 --- a/docs/DOCKER_TO_WINDOWS_MIGRATION.md +++ b/docs/DOCKER_TO_WINDOWS_MIGRATION.md @@ -5,9 +5,8 @@ Linux**, with an Original ALPR v0.1.9-compatible database or Community v0.1.20+ on PostgreSQL 13 or 17. It creates the same verified folder that Windows Setup imports. Do not update, replace or delete the old Docker database to export it. -These controls are part of the Docker migration exporter change; use a release -that includes them. Older releases, including v0.1.55, do not include this -graphical Docker exporter. +These controls are available in Community v0.1.56 and later. Older releases, +including v0.1.55, do not include this graphical Docker exporter. ## Choose the correct backup tool From 0b90303191e63da7644c15279d637f4135fe69b9 Mon Sep 17 00:00:00 2001 From: prsmith777 Date: Wed, 21 Oct 2026 19:09:36 -0600 Subject: [PATCH 12/12] Wait through systemd user-bus startup permissions --- CHANGELOG.md | 2 ++ scripts/community-update-agent-setup.mjs | 8 +++++++- test/community-update-agent-setup.test.mjs | 22 ++++++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fbb402..c058d64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ the selected source application's prior running/stopped state. - Refuse conflicting database settings, changed source data, overwritten backups and populated import targets. Imports do not merge old and new histories. +- Wait through temporary systemd user-bus permissions during Linux update-helper + startup, within the existing readiness timeout. - Update User Guide 3.24, migration/install instructions and the product roadmap to distinguish native backups from Docker exports and preserve newer records. diff --git a/scripts/community-update-agent-setup.mjs b/scripts/community-update-agent-setup.mjs index ecea9fe..f8cbfbd 100644 --- a/scripts/community-update-agent-setup.mjs +++ b/scripts/community-update-agent-setup.mjs @@ -53,7 +53,13 @@ export async function ensureCommunityUpdateAgent(options = {}) { }); let busReady = false; for (let attempt = 0; attempt < attempts; attempt++) { - if (await busAvailable()) { busReady = true; break; } + try { + if (await busAvailable()) { busReady = true; break; } + } catch (error) { + // systemd may create the runtime directory before assigning its owner. + // Retry only that startup condition within the existing readiness limit. + if (error.code !== "EACCES") throw error; + } await wait(500); } if (!busReady) throw new Error("The owner's systemd user bus is unavailable; run the recovery command from that account's login session"); diff --git a/test/community-update-agent-setup.test.mjs b/test/community-update-agent-setup.test.mjs index 9ba8aa3..0abb3e3 100644 --- a/test/community-update-agent-setup.test.mjs +++ b/test/community-update-agent-setup.test.mjs @@ -101,3 +101,25 @@ test("unsupported hosts, root, unavailable buses, and denied sudo leave a clear assert.match(f.messages.join("\n"), /\.\/alpr-community agent install/); } finally { await f.cleanup(); } }); + +test("helper readiness waits for temporary user-bus permissions but bounds persistent denial", async () => { + const f = await fixture(); + try { + let probes = 0; + const transient = async () => { + if (++probes === 1) throw Object.assign(new Error("user manager starting"), { code: "EACCES" }); + return true; + }; + assert.equal((await ensureCommunityUpdateAgent({ ...f.options, busAvailable: transient })).status, "ready"); + assert.equal(probes, 2); + f.commands.length = 0; + probes = 0; + const denied = async () => { probes++; throw Object.assign(new Error("denied"), { code: "EACCES" }); }; + await assert.rejects(ensureCommunityUpdateAgent({ ...f.options, busAvailable: denied }), /user bus is unavailable/); + assert.equal(probes, f.options.readyAttempts); + assert.ok(!f.commands.some(c => c.command === "systemctl")); + await assert.rejects(ensureCommunityUpdateAgent({ ...f.options, busAvailable: async () => { + throw Object.assign(new Error("filesystem failure"), { code: "EIO" }); + } }), /filesystem failure/); + } finally { await f.cleanup(); } +});