Enhance Docker and PowerShell scripts for improved functionality and maintainability
All checks were successful
Publish FaceAI Container / publish (push) Successful in 6m52s

- Updated Dockerfile to include default MySQL client for better database interaction.
- Modified entrypoint.sh to support additional workspace for legacy applications and added MySQL readiness check before startup.
- Enhanced PowerShell script for trimming MySQL dumps to include overlay dumps and improved error handling for missing race and user IDs.
- Added new image files and face encoding pickles for various projects, ensuring comprehensive data availability.
- Removed outdated face encoding pickle from PISA directory to maintain data relevance.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
MaddoScientisto 2026-04-22 22:45:44 +02:00
commit dd7d4c865b
54 changed files with 492 additions and 144 deletions

View file

@ -7,8 +7,7 @@ It includes:
- a Vue frontend for the FaceAI upload and polling flow
- a Node/Express backend for session exchange, queueing, and return handoff
- a dedicated processor runner that consumes matcher jobs from Redis and executes `face_matcher`
- a local legacy simulator so the launch and return flow can be tested without the old Java site
- a Dockerized PHP Apache stack for exercising the real `www/faceai_handoff.php` and `www/faceai_return.php` bridge files
- a local Dockerized Tomcat/JSP stack so the launch and return flow can be tested against the real legacy race pages under `www/`
## Structure
@ -24,32 +23,38 @@ faceai/
## Runtime Topology
The scaffold currently expects four runtime roles:
The production-oriented application topology is still three services:
- `faceai`: public HTTP service on port `3001`, serving the built Vue app and the authenticated API
- `processor`: background matcher runner consuming BullMQ jobs from Redis and executing the Linux `face_matcher` binary
- `redis`: short-lived queue and search-state store
- `legacy-php`: local-only PHP Apache simulator for exercising the real bridge files under `www/`
For hosted deployment, the long-lived application topology is `faceai` + `processor` + `redis`. The PHP simulator stays local-only and the real legacy site remains on its existing stack.
The checked-in development override now expands that into the full local integration stack:
- `tomcat-www`: local Tomcat runtime serving the real legacy JSP race pages from `www/`
- `mysql`: local legacy database used by the Tomcat stack
- `maildump`: local SMTP sink and viewer for the Tomcat stack
That means the local FaceAI flow now runs against the same legacy page type the real site uses instead of a separate PHP bridge container.
## What The End-To-End Local Test Covers
The local simulator exercises the exact flow the plan is aiming for:
The local Tomcat stack exercises the exact flow the current integration is aiming for:
1. a legacy-like race page loads the original `www/_js/rus-ecom-240621.js` script and shows a `Face ID` button instead of `tipoPuntoFoto`
2. clicking it hits the real PHP handoff bridge at `www/faceai_handoff.php`
2. clicking it uses the handoff URL emitted by the real JSP race page and launches FaceAI through `http://localhost:3001/dev/legacy/launch`
3. the backend signs a short-lived handoff token and redirects to the Vue app
4. the Vue app exchanges the token for its own FaceAI session cookie
5. the user uploads a selfie and starts a Redis-backed race-scoped search
6. the frontend polls until the job completes
7. FaceAI requests a signed return URL
8. the browser is redirected back to the real PHP return bridge at `www/faceai_return.php`
9. the PHP bridge fetches the signed result from FaceAI and renders a filtered legacy-like race page
8. the browser is redirected back to the real Tomcat race page with `faceaiResultId` and `faceaiToken`
9. the legacy race-page JavaScript fetches the signed result from FaceAI, stores the hydrated match payload in browser storage, and reloads the cleaned race URL
10. the same legacy race page renders the FaceAI-filtered gallery view from that stored payload
## Local Testing With The Legacy PHP Simulator
## Local Testing With The Merged Legacy JSP Stack
This is the recommended local test path because it exercises the public site, the processor, Redis, and the real PHP bridge files together.
This is the recommended local test path because it exercises the public site, the processor, Redis, and the real legacy JSP page flow together.
### Prerequisites
@ -78,13 +83,15 @@ The local development stack started by the command above combines the base file
- FaceAI public site on `http://localhost:3001`
- processor runner on the internal Compose network
- Redis on the internal Compose network
- PHP Apache serving `../www` on `http://localhost:8080`
- Tomcat serving the real local legacy site on `http://localhost:8080`
- MySQL for the local legacy stack
- Maildump for the local legacy stack on `http://localhost:8025`
The local stack also mounts:
- `../test_pkl` into both the public FaceAI container and the processor container as the shared read-only PKL dataset root
- `./logs` into both the public FaceAI container and the processor container as the persistent diagnostics directory
- `../www` into the PHP container so the real bridge files are used
- `../www` into the Tomcat build/runtime inputs so the real legacy JSP pages and assets are used
The `processor` service is built from `docker/processor.Dockerfile` using the repository root as Docker build context. That image copies only the checked-in Unix `face_matcher` into the image, so the matcher is baked into the processor runtime without bringing along the other Unix or Windows binaries.
@ -110,22 +117,23 @@ The current bundled Linux `face_matcher` binary is a PyInstaller build that requ
Open:
```text
http://localhost:8080/faceai_simulator.php?raceId=202&lang=it
http://localhost:8080/Foto2.abl?id_gara=1018557&pageRow=96&pageNumber=1
```
That page simulates the legacy race page, loads the original race-page JavaScript from `www/_js/rus-ecom-240621.js`, lets the script replace the visible `tipoPuntoFoto` selector with the new `Face ID` button, and launches the real PHP handoff bridge at `www/faceai_handoff.php`.
That is the real local Tomcat race page. It loads the original race-page JavaScript from `www/_js/rus-ecom-240621.js`, lets the script replace the visible `tipoPuntoFoto` selector with the new `Face ID` button, and launches the backend handoff endpoint configured through the JSP page.
### Expected Local Flow
Use the page above and verify this sequence:
1. the simulator page renders on port `8080`
1. the real local race page renders on port `8080`
2. the visible checkpoint selector is replaced with the `Face ID` launch button
3. clicking `Face ID` redirects through `faceai_handoff.php` into `http://localhost:3001/auth/callback?token=...`
3. clicking `Face ID` redirects through `http://localhost:3001/dev/legacy/launch` into `http://localhost:3001/auth/callback?token=...`
4. the FaceAI app establishes its session and loads the upload flow
5. uploading a selfie creates a queued search that the processor picks up
6. when polling completes, FaceAI redirects back to `http://localhost:8080/faceai_return.php?...`
7. the PHP return page renders the filtered photo list from the FaceAI result payload
6. when polling completes, FaceAI redirects back to the same race page with `faceaiResultId` and `faceaiToken`
7. the race-page JavaScript hydrates the result from FaceAI and reloads the cleaned legacy URL
8. the filtered photo list renders from the hydrated FaceAI result payload
### Rebuild Notes
@ -143,7 +151,7 @@ docker compose --env-file .env.development down
### Automated End-To-End Test
The workspace now includes a Playwright suite that drives the PHP simulator, the FaceAI app, and the processor end to end.
The workspace now includes a Playwright suite that drives the real local Tomcat race page, the FaceAI app, and the processor end to end.
From this folder, run:
@ -157,10 +165,10 @@ The suite will:
- build the frontend bundle
- start `docker compose --env-file .env.development up --build -d`
- open `http://localhost:8080/faceai_simulator.php?raceId=202&lang=it`
- open `http://localhost:8080/Foto2.abl?id_gara=1018557&pageRow=96&pageNumber=1`
- click the `Face ID` launch button injected by `www/_js/rus-ecom-240621.js`
- upload `test_pkl/test_images/DSC_1960.JPG`
- wait for the processor to complete and for FaceAI to redirect to `faceai_return.php`
- wait for the processor to complete and for FaceAI to redirect back to the legacy race page
- assert the filtered legacy result contains the expected `6` matches and includes `DSC_1960.JPG`
- validate `faceai/logs/backend.log`, `faceai/logs/processor.log`, and the per-search `worker.log` and `matcher.log` for the run
- stop the Compose stack automatically when the suite finishes
@ -238,7 +246,7 @@ If a running processor still reports `ENOENT`, the deployed image was built befo
## Optional Backend And Frontend Dev Loop
If you only want to iterate on the app without the PHP simulator, you can still run the public site and the processor separately. The queue-backed flow now requires Redis and the processor, so `npm run dev` alone is no longer the full stack.
If you only want to iterate on the app without the local Tomcat stack, you can still run the public site and the processor separately. The queue-backed flow now requires Redis and the processor, so `npm run dev` alone is no longer the full stack.
One workable loop is:
@ -252,7 +260,7 @@ Then start the processor in a second shell, either with its own local environmen
## Docker Compose Deployment For The Public Site And Matcher Runner
The checked-in `docker-compose.yml` is now the production-ready base stack for hosted deployment. The checked-in `docker-compose.override.yml` is the development overlay that restores the local PHP simulator, workspace bind mounts, and development-oriented commands.
The checked-in `docker-compose.yml` is now the production-ready base stack for hosted deployment. The checked-in `docker-compose.override.yml` is the development overlay that restores the local Tomcat/JSP stack, workspace bind mounts, and development-oriented commands.
Because Docker Compose auto-loads `docker-compose.override.yml` when it is present in the same directory, production-style runs from this workspace must explicitly select only the base file.
@ -367,8 +375,8 @@ After the Compose stack is up, validate at least the following:
2. The legacy handoff endpoint redirects to `https://faceai.../auth/callback?token=...`.
3. FaceAI can exchange the token and establish a session.
4. A search is enqueued in Redis and picked up by the processor.
5. Completing a search produces a redirect URL that points to `FACEAI_LEGACY_RETURN_URL`.
6. The legacy return endpoint can resolve the signed result and render the filtered race page.
5. Completing a search produces a redirect URL that points to the intended legacy return target.
6. The legacy page can resolve the signed result and render the filtered race page.
### Current Production Limitations
@ -411,9 +419,10 @@ NODE_ENV=development
FACEAI_PORT=3001
FACEAI_FRONTEND_URL=http://localhost:3001
FACEAI_PUBLIC_BASE_URL=http://localhost:3001
FACEAI_LEGACY_RETURN_URL=http://localhost:8080/faceai_return.php
FACEAI_LEGACY_RETURN_MODE=direct
FACEAI_LEGACY_HOME_URL=http://localhost:8080/index.jsp
FACEAI_FEATURE_ENABLED=1
FACEAI_HANDOFF_URL=http://localhost:3001/dev/legacy/launch
FACEAI_SHARED_SECRET=change-me
FACEAI_SESSION_COOKIE=rus_faceai_session
FACEAI_REDIS_URL=redis://redis:6379
@ -425,12 +434,13 @@ FACEAI_PKL_ROOT=/data/pkl
FACEAI_MATCHER_BINARY=/app/bin/face_matcher
```
If you want FaceAI to return through the new PHP bridge prepared under `www`, point `FACEAI_LEGACY_RETURN_URL` to that endpoint instead, for example `http://localhost/faceai_return.php` or the equivalent URL in your local PHP setup.
If you want FaceAI to force the older bridge-style return path instead, set `FACEAI_LEGACY_RETURN_MODE=bridge` and point `FACEAI_LEGACY_RETURN_URL` at the appropriate legacy bridge endpoint.
In the development override, that wiring is already done with:
In the current development override, the local legacy handoff wiring is already done with:
```text
FACEAI_LEGACY_RETURN_URL=http://localhost:8080/faceai_return.php
FACEAI_HANDOFF_URL=http://localhost:3001/dev/legacy/launch
FACEAI_LEGACY_RETURN_MODE=direct
FACEAI_LEGACY_HOME_URL=http://localhost:8080/index.jsp
```
@ -438,18 +448,17 @@ The development override also keeps the log wiring with `./logs:/data/logs`, so
The Compose contract now also includes an HTTP healthcheck on the public FaceAI service and a Redis readiness check. That makes `docker compose ps` meaningful during rollout: `faceai` only becomes healthy after `GET /health` returns `{"ok":true}`, and both the public site and the processor wait for Redis readiness before their own startup sequence begins.
The local PHP simulator also needs the legacy bridge feature flag enabled:
The local Tomcat stack also needs the legacy bridge feature flag enabled on the JSP side:
```text
FACEAI_FEATURE_ENABLED=1
```
The checked-in `docker-compose.override.yml` sets that on the `legacy-php` service so the simulator can launch the FaceAI handoff flow locally.
The checked-in `docker-compose.override.yml` sets that on the local `tomcat-www` service so the race page can launch the FaceAI handoff flow locally.
## Notes
- Search orchestration now uses Redis and a dedicated processor worker.
- The checked-in base Compose file is production-oriented, while the checked-in override is development-only.
- The local legacy simulator is intentionally backend-driven so the handoff can be tested without compiling the existing Java application.
- `www/faceai_simulator.php` exists only for local testing. It does not replace the actual JSP race page.
- The local development flow now uses the actual Tomcat-served JSP race page instead of a separate PHP simulator bridge.
- The final legacy integration still needs a real signed identity source and a real return-filter implementation on the old site.

View file

@ -86,11 +86,12 @@ services:
FACEAI_DEV_DISPLAY_NAME: ${FACEAI_DEV_DISPLAY_NAME:-Local Model User}
FACEAI_DEV_EMAIL: ${FACEAI_DEV_EMAIL:-local.model.user@example.invalid}
FACEAI_DEV_MEMBERSHIP_STATUS: ${FACEAI_DEV_MEMBERSHIP_STATUS:-active}
LOCAL_APP_INSTANCE: ${LOCAL_APP_INSTANCE:-local-model}
LOCAL_APP_INSTANCE: ${LOCAL_APP_INSTANCE:-main}
LOCAL_DOCBASE: /data/docbase/
FACEAI_FEATURE_ENABLED: ${FACEAI_FEATURE_ENABLED:-1}
volumes:
- ../www:/workspace/www:ro
- ../rus:/workspace/rus:ro
- ../test_pkl:/workspace/test_pkl:ro
- ../local-jsp-docker/runtime/docbase:/data/docbase
- ../local-jsp-docker/runtime/tomcat-work:/usr/local/tomcat/work
@ -111,15 +112,21 @@ services:
- --max_allowed_packet=1G
- --net_read_timeout=600
- --net_write_timeout=600
- --lower_case_table_names=1
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: pg
MYSQL_ROOT_HOST: '%'
LOCAL_DB_SEED_DUMP: ${LOCAL_DB_SEED_DUMP:-pg-model-seed-trimmed-20260421.sql}
LOCAL_DB_SEED_DUMP: ${LOCAL_DB_SEED_DUMP:-pg-local-purpose-seed-20260422.sql}
LOCAL_DB_OVERLAY_DUMP: ${LOCAL_DB_OVERLAY_DUMP:-}
LOCAL_DOCBASE: /data/docbase/
LOCAL_MAIL_SMTP_HOST: maildump
LOCAL_MAIL_SMTP_PORT: 1025
LOCAL_SOURCE_DIR: /workspace/www/
LOCAL_TEST_USER_ID: ${LOCAL_TEST_USER_ID:-2}
LOCAL_TEST_USER_LOGIN: ${LOCAL_TEST_USER_LOGIN:-test}
LOCAL_TEST_USER_PASSWORD: ${LOCAL_TEST_USER_PASSWORD:-test1}
LOCAL_TEST_USER_EMAIL: ${LOCAL_TEST_USER_EMAIL:-localtest@regalamiunsorriso.test}
volumes:
- mysql-data:/var/lib/mysql
- ../db:/seed:ro
@ -128,11 +135,9 @@ services:
healthcheck:
test:
- CMD
- mysqladmin
- ping
- -h
- 127.0.0.1
- -proot
- sh
- -lc
- mysql -uroot -p"$$MYSQL_ROOT_PASSWORD" -Nse "SELECT CASE WHEN EXISTS (SELECT 1 FROM pg.parm WHERE codice = 'REWRITE_URL_ENABLE' AND numero = 1) AND EXISTS (SELECT 1 FROM pg.gara LIMIT 1) THEN 1 ELSE 0 END" | grep -qx 1
interval: 10s
timeout: 5s
retries: 30

View file

@ -1,8 +1,10 @@
const { test, expect } = require('@playwright/test');
const {
ensureLocalAuthenticatedRacePage,
EXPECTED_MATCH_COUNT,
FACEAI_BASE_URL,
LEGACY_RACE_ID,
SELFIE_NAME,
buildHandoffUrl,
buildSimulatorUrl,
getSearchArtifacts,
@ -61,6 +63,7 @@ async function readLaunchUrlFromLegacyPage(page) {
}
async function startSearch(page, selfieName) {
const selfieLabel = selfieName.split(/[\\/]+/u).pop();
const createResponsePromise = page.waitForResponse((response) => {
return response.url().includes('/api/searches')
&& response.request().method() === 'POST'
@ -68,7 +71,7 @@ async function startSearch(page, selfieName) {
});
await page.locator('input[type="file"]').setInputFiles(getSelfiePath(selfieName));
await expect(page.getByText(selfieName)).toBeVisible();
await expect(page.getByText(selfieLabel)).toBeVisible();
await page.getByRole('button', { name: 'Avvia ricerca Face ID' }).click();
const createResponse = await createResponsePromise;
@ -165,40 +168,40 @@ test('runs the legacy Tomcat flow through FaceAI and returns to the filtered leg
await launchFromSimulator(page, {
raceId: LEGACY_RACE_ID,
raceSlug: 'livorno',
raceName: 'Livorno',
raceFolder: 'LIVORNO'
raceSlug: 'isolotto',
raceName: 'Festa sociale UP Isolotto',
raceFolder: 'ISOLOTTO'
});
const search = await startSearch(page, 'DSC_1960.JPG');
const search = await startSearch(page, SELFIE_NAME);
await waitForLegacyResult(page, LEGACY_RACE_ID, EXPECTED_MATCH_COUNT);
await verifySearchLogs(search.id, {
expectedMatchCount: EXPECTED_MATCH_COUNT,
expectedSelfieName: 'DSC_1960.JPG'
expectedSelfieName: SELFIE_NAME.split(/[\\/]+/u).pop()
});
});
test('builds the legacy FaceAI handoff URL with the exact local race storage metadata', async ({ page }) => {
await page.goto(buildSimulatorUrl({
raceId: LEGACY_RACE_ID,
raceSlug: 'livorno',
raceName: 'Livorno',
raceSlug: 'isolotto',
raceName: 'Festa sociale UP Isolotto',
raceYear: '2026',
raceMonthFolder: '04.APRILE',
raceFolder: 'LIVORNO'
raceFolder: 'ISOLOTTO'
}), { waitUntil: 'domcontentloaded' });
await expect(page.locator('#faceAiRaceYear')).toHaveValue('2026');
await expect(page.locator('#faceAiRaceMonthFolder')).toHaveValue('04.APRILE');
await expect(page.locator('#faceAiRaceFolder')).toHaveValue('LIVORNO');
await expect(page.locator('#faceAiRaceFolder')).toHaveValue('ISOLOTTO');
const launchUrl = await readLaunchUrlFromLegacyPage(page);
expect(launchUrl.searchParams.get('raceYear')).toBe('2026');
expect(launchUrl.searchParams.get('raceMonthFolder')).toBe('04.APRILE');
expect(launchUrl.searchParams.get('raceFolder')).toBe('LIVORNO');
expect(launchUrl.searchParams.get('raceStorageRelativeDir')).toBe('2026/04.APRILE/LIVORNO');
expect(launchUrl.searchParams.get('raceFolder')).toBe('ISOLOTTO');
expect(launchUrl.searchParams.get('raceStorageRelativeDir')).toBe('2026/04.APRILE/ISOLOTTO');
});
test('shows the unsupported-race message when the current race has no PKL data and lets the user go back', async ({ page }) => {
@ -300,6 +303,19 @@ test('rejects a not-logged-in user after clicking the Face ID button and sends t
await expect(page.locator('#faceAiErrorModalMessage')).toContainText('Il servizio Face ID non e al momento disponibile. Riprova piu tardi.');
});
test('authenticates with the seeded local user and lets that user browse and launch the Livorno race page', async ({ page }) => {
await ensureLocalAuthenticatedRacePage(page, {
raceId: '1018557',
raceName: 'VIVICITTA LIVORNO',
raceYear: '2026',
raceMonthFolder: '04.APRILE',
raceFolder: 'LIVORNO'
});
await page.locator('#faceaiLaunchButton').click();
await waitForFaceAiHome(page);
});
test('shows the no-face message and allows the user to return to the race page', async ({ page }) => {
test.slow();
@ -341,8 +357,8 @@ test('opens the file chooser when the user clicks the upload button', async ({ p
await page.getByRole('button', { name: 'Scegli immagine' }).click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(getSelfiePath('DSC_1960.JPG'));
await expect(page.getByText('DSC_1960.JPG')).toBeVisible();
await fileChooser.setFiles(getSelfiePath(SELFIE_NAME));
await expect(page.getByText(SELFIE_NAME.split(/[\\/]+/u).pop())).toBeVisible();
await expect(page.getByRole('button', { name: 'Avvia ricerca Face ID' })).toBeEnabled();
});
@ -364,7 +380,7 @@ test('lets the user retry with a valid photo after a no-face upload and then ret
await expect(page.locator('.faceai-feedback')).toContainText('Nessun volto rilevato nella foto caricata');
await expect(page.locator('input[type="file"]')).toBeEnabled();
const retrySearch = await startSearch(page, 'DSC_1960.JPG');
const retrySearch = await startSearch(page, SELFIE_NAME);
await waitForLegacyResult(page, '202', EXPECTED_MATCH_COUNT);
await verifySearchLogs(noFaceSearch.id, {
@ -373,7 +389,7 @@ test('lets the user retry with a valid photo after a no-face upload and then ret
});
await verifySearchLogs(retrySearch.id, {
expectedMatchCount: EXPECTED_MATCH_COUNT,
expectedSelfieName: 'DSC_1960.JPG'
expectedSelfieName: SELFIE_NAME.split(/[\\/]+/u).pop()
});
});
@ -403,7 +419,7 @@ test('allows two users to process different photos at the same time', async ({ b
]);
const [searchOne, searchTwo] = await Promise.all([
startSearch(pages[0], 'DSC_1960.JPG'),
startSearch(pages[0], SELFIE_NAME),
startSearch(pages[1], 'DSC_1987.JPG')
]);
@ -413,7 +429,7 @@ test('allows two users to process different photos at the same time', async ({ b
]);
await Promise.all([
verifySearchLogs(searchOne.id, { expectedSelfieName: 'DSC_1960.JPG' }),
verifySearchLogs(searchOne.id, { expectedSelfieName: SELFIE_NAME.split(/[\\/]+/u).pop() }),
verifySearchLogs(searchTwo.id, { expectedSelfieName: 'DSC_1987.JPG' })
]);
} finally {
@ -436,7 +452,7 @@ test('queues the third user until a worker is free and then completes all three
]);
const [searchOne, searchTwo, searchThree] = await Promise.all([
startSearch(pages[0], 'DSC_1960.JPG'),
startSearch(pages[0], SELFIE_NAME),
startSearch(pages[1], 'DSC_1987.JPG'),
startSearch(pages[2], 'DSC_2058.JPG')
]);
@ -477,7 +493,7 @@ test('queues the third user until a worker is free and then completes all three
await Promise.all(pages.map((page) => waitForLegacyResult(page, '202')));
await Promise.all([
verifySearchLogs(searchOne.id, { expectedSelfieName: 'DSC_1960.JPG' }),
verifySearchLogs(searchOne.id, { expectedSelfieName: SELFIE_NAME.split(/[\\/]+/u).pop() }),
verifySearchLogs(searchTwo.id, { expectedSelfieName: 'DSC_1987.JPG' }),
verifySearchLogs(searchThree.id, { expectedSelfieName: 'DSC_2058.JPG' })
]);

View file

@ -7,12 +7,15 @@ const WORKSPACE_ROOT = path.resolve(ROOT_DIR, '..');
const LOG_ROOT = path.join(ROOT_DIR, 'logs');
const SEARCH_LOG_ROOT = path.join(LOG_ROOT, 'searches');
const FACEAI_BASE_URL = process.env.FACEAI_E2E_BASE_URL || 'http://127.0.0.1:3001';
const SIMULATOR_URL = process.env.FACEAI_E2E_SIMULATOR_URL || 'http://127.0.0.1:8080/Foto2.abl?id_gara=1018557&pageRow=96&pageNumber=1';
const SIMULATOR_URL = process.env.FACEAI_E2E_SIMULATOR_URL || 'http://127.0.0.1:8080/Foto2.abl?id_gara=1018547&pageRow=96&pageNumber=1';
const LEGACY_BASE_URL = process.env.FACEAI_E2E_LEGACY_BASE_URL || 'http://127.0.0.1:8080';
const LEGACY_HOME_URL = process.env.FACEAI_E2E_LEGACY_HOME_URL || `${LEGACY_BASE_URL}/index.jsp`;
const SELFIE_NAME = process.env.FACEAI_E2E_SELFIE || 'DSC_1960.JPG';
const LEGACY_LOGIN_URL = process.env.FACEAI_E2E_LEGACY_LOGIN_URL || `${LEGACY_BASE_URL}/login_clienti.html`;
const LEGACY_USERNAME = process.env.FACEAI_E2E_LEGACY_USERNAME || 'test';
const LEGACY_PASSWORD = process.env.FACEAI_E2E_LEGACY_PASSWORD || 'test1';
const SELFIE_NAME = process.env.FACEAI_E2E_SELFIE || '2026/04.APRILE/ISOLOTTO/01.FOTO/CKL_8459.JPG';
const EXPECTED_MATCH_COUNT = Number(process.env.FACEAI_E2E_EXPECTED_MATCH_COUNT || '6');
const LEGACY_RACE_ID = process.env.FACEAI_E2E_RACE_ID || '1018557';
const LEGACY_RACE_ID = process.env.FACEAI_E2E_RACE_ID || '1018547';
function quoteShellArg(value) {
if (!/[\s"]/u.test(value)) {
@ -114,17 +117,22 @@ async function waitForHttp(url, validate, timeoutMs = 3 * 60 * 1000) {
}
function getSelfiePath(fileName = SELFIE_NAME) {
const relativeSegments = fileName.split(/[\\/]+/u);
if (relativeSegments.length > 1) {
return path.join(WORKSPACE_ROOT, 'test_pkl', ...relativeSegments);
}
return path.join(WORKSPACE_ROOT, 'test_pkl', 'test_images', fileName);
}
function buildSimulatorUrl({
raceId = LEGACY_RACE_ID,
lang = 'it',
raceSlug = 'livorno',
raceName = 'Livorno',
raceSlug = 'isolotto',
raceName = 'Festa sociale UP Isolotto',
raceYear = '2026',
raceMonthFolder = '04.APRILE',
raceFolder = 'LIVORNO',
raceFolder = 'ISOLOTTO',
pageRow = '96',
pageNumber = '1'
} = {}) {
@ -139,11 +147,11 @@ function buildSimulatorUrl({
function buildHandoffUrl({
raceId = LEGACY_RACE_ID,
lang = 'it',
raceSlug = 'livorno',
raceName = 'Livorno',
raceSlug = 'isolotto',
raceName = 'Festa sociale UP Isolotto',
raceYear = '2026',
raceMonthFolder = '04.APRILE',
raceFolder = 'LIVORNO',
raceFolder = 'ISOLOTTO',
userId = '1',
displayName = `Local Test User ${userId}`,
email = `local-test-${userId}@example.invalid`,
@ -166,6 +174,86 @@ function buildHandoffUrl({
return url.toString();
}
function buildLegacyLoginFormData() {
if (!LEGACY_USERNAME || !LEGACY_PASSWORD) {
throw new Error('FACEAI_E2E_LEGACY_USERNAME and FACEAI_E2E_LEGACY_PASSWORD must be set before running authenticated local E2E checks.');
}
return {
login: LEGACY_USERNAME,
pwd: LEGACY_PASSWORD,
cmdIU: 'check',
act: '',
thePage: ''
};
}
async function performLocalLoginRequest(requestContext) {
const response = await requestContext.post(`${LEGACY_BASE_URL}/Logon.abl`, {
form: buildLegacyLoginFormData(),
failOnStatusCode: false
});
const finalUrl = response.url();
const bodyText = await response.text();
if (!response.ok()) {
throw new Error(`Local login request failed with HTTP ${response.status()} at ${finalUrl}`);
}
if (/login_clienti|Username \/ Email|Password/iu.test(bodyText) && !/user_logout|dettaglio_clienti|Il mio account/iu.test(bodyText)) {
throw new Error(`Local login request appears to have remained on the login page at ${finalUrl}`);
}
}
async function expectLocalRacePageLoaded(page) {
await page.waitForSelector('form[onsubmit="return searching()"]', { state: 'visible' });
const raceId = await page.locator('#id_gara').inputValue();
if (!/\d+/u.test(raceId)) {
throw new Error(`Expected the local race page to expose a numeric race id, got: ${raceId}`);
}
await page.waitForSelector('#faceaiLaunchButton', { state: 'visible' });
await page.waitForFunction(() => {
return document.querySelectorAll('a[data-faceai-photo-id] img.thumb').length > 0;
}, null, {
timeout: 30 * 1000
});
}
async function ensureLocalAuthenticatedRacePage(page, options = {}) {
await page.goto(LEGACY_LOGIN_URL, { waitUntil: 'domcontentloaded' });
await page.locator('#login').fill(LEGACY_USERNAME);
await page.locator('#pwd').fill(LEGACY_PASSWORD);
const submitLocator = page.locator('input[type="submit"], button[type="submit"], a.btn').filter({ hasText: /Accedi|Sign in/i }).first();
if (await submitLocator.count()) {
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
submitLocator.click()
]);
} else {
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
page.evaluate(() => {
const form = document.querySelector('form[action="Logon.abl"]');
if (!form) {
throw new Error('Local login page did not expose a Logon.abl form.');
}
const cmdIUField = form.querySelector('[name="cmdIU"]');
if (cmdIUField) {
cmdIUField.value = 'check';
}
form.submit();
})
]);
}
await page.goto(buildSimulatorUrl(options), { waitUntil: 'domcontentloaded' });
await expectLocalRacePageLoaded(page);
}
function getSearchArtifacts(searchId) {
const searchRoot = path.join(SEARCH_LOG_ROOT, searchId);
return {
@ -188,15 +276,22 @@ module.exports = {
FACEAI_BASE_URL,
LEGACY_BASE_URL,
LEGACY_HOME_URL,
LEGACY_LOGIN_URL,
LEGACY_PASSWORD,
SIMULATOR_URL,
SELFIE_NAME,
EXPECTED_MATCH_COUNT,
LEGACY_RACE_ID,
LEGACY_USERNAME,
buildHandoffUrl,
buildLegacyLoginFormData,
buildSimulatorUrl,
dockerCompose,
ensureLocalAuthenticatedRacePage,
expectLocalRacePageLoaded,
getSearchArtifacts,
getSelfiePath,
performLocalLoginRequest,
prepareHostState,
readUtf8,
runCommand,

View file

@ -2,12 +2,12 @@
This stack boots the `www` Tomcat webapp locally, seeds MySQL from the checked-in `pg` dump, overrides environment-sensitive `PARM` rows, and captures outgoing email into files.
The default local model seed is `db/pg-model-seed-trimmed-20260421.sql`.
The default local model seed is `db/pg-local-purpose-seed-20260422.sql`.
## Services
- `tomcat-www`: Tomcat 9 on Java 11 serving a runtime copy of `www`
- `mysql`: MySQL 8 with first-boot import of `db/pg-model-seed-trimmed-20260421.sql` by default
- `mysql`: MySQL 8 with first-boot import of `db/pg-local-purpose-seed-20260422.sql` by default
- `maildump`: local SMTP sink writing `.eml`, `.txt`, and `.html` payloads to disk
## What Gets Mocked
@ -31,11 +31,15 @@ Mail output is written under `local-jsp-docker/runtime/maildump/out`.
## First Boot
The first MySQL start imports the file named by `LOCAL_DB_SEED_DUMP`, which defaults to `db/pg-model-seed-trimmed-20260421.sql`.
The first MySQL start imports the file named by `LOCAL_DB_SEED_DUMP`, which defaults to `db/pg-local-purpose-seed-20260422.sql`.
After the import finishes, the local override script updates `PARM` with local-safe values.
After the import finishes, the local override script updates `PARM` with local-safe values and forces a deterministic legacy login for local testing.
The Tomcat runtime also patches the deployed `WEB-INF/web.xml` so the `instance` context-param is `local-model` instead of `main`. That keeps the startup DB updater servlets from mutating the model database during local boot.
The default local test credential is `test` / `test1`. Override it with `LOCAL_TEST_USER_LOGIN`, `LOCAL_TEST_USER_PASSWORD`, and `LOCAL_TEST_USER_EMAIL` if needed.
The local MySQL container initializes with `lower_case_table_names=1` because the legacy app issues uppercase table names such as `PARM` on Linux. If you change this setting or are coming from an older volume, rebuild from a fresh volume with `docker compose down -v` before starting again.
The Tomcat runtime patches the deployed `WEB-INF/web.xml` from `LOCAL_APP_INSTANCE`, which now defaults to `main` so the legacy startup initialization path runs locally as it does on the site. You can still override `LOCAL_APP_INSTANCE` when you need to suppress that startup path for a targeted debug session.
## Restart Workflow

View file

@ -16,7 +16,7 @@ services:
FACEAI_DEV_DISPLAY_NAME: ${FACEAI_DEV_DISPLAY_NAME:-Local Model User}
FACEAI_DEV_EMAIL: ${FACEAI_DEV_EMAIL:-local.model.user@example.invalid}
FACEAI_DEV_MEMBERSHIP_STATUS: ${FACEAI_DEV_MEMBERSHIP_STATUS:-active}
LOCAL_APP_INSTANCE: ${LOCAL_APP_INSTANCE:-local-model}
LOCAL_APP_INSTANCE: ${LOCAL_APP_INSTANCE:-main}
LOCAL_DOCBASE: /data/docbase/
FACEAI_FEATURE_ENABLED: ${FACEAI_FEATURE_ENABLED:-1}
FACEAI_FRONTEND_URL: ${FACEAI_FRONTEND_URL:-http://localhost:3001}
@ -26,6 +26,7 @@ services:
FACEAI_IDENTITY_COOKIE: ${FACEAI_IDENTITY_COOKIE:-rus_faceai_identity}
volumes:
- ../www:/workspace/www:ro
- ../rus:/workspace/rus:ro
- ../test_pkl:/workspace/test_pkl:ro
- ./runtime/docbase:/data/docbase
- ./runtime/tomcat-work:/usr/local/tomcat/work
@ -46,15 +47,21 @@ services:
- --max_allowed_packet=1G
- --net_read_timeout=600
- --net_write_timeout=600
- --lower_case_table_names=1
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: pg
MYSQL_ROOT_HOST: '%'
LOCAL_DB_SEED_DUMP: ${LOCAL_DB_SEED_DUMP:-pg-model-seed-trimmed-20260421.sql}
LOCAL_DB_SEED_DUMP: ${LOCAL_DB_SEED_DUMP:-pg-local-purpose-seed-20260422.sql}
LOCAL_DB_OVERLAY_DUMP: ${LOCAL_DB_OVERLAY_DUMP:-}
LOCAL_DOCBASE: /data/docbase/
LOCAL_MAIL_SMTP_HOST: maildump
LOCAL_MAIL_SMTP_PORT: 1025
LOCAL_SOURCE_DIR: /workspace/www/
LOCAL_TEST_USER_ID: ${LOCAL_TEST_USER_ID:-2}
LOCAL_TEST_USER_LOGIN: ${LOCAL_TEST_USER_LOGIN:-test}
LOCAL_TEST_USER_PASSWORD: ${LOCAL_TEST_USER_PASSWORD:-test1}
LOCAL_TEST_USER_EMAIL: ${LOCAL_TEST_USER_EMAIL:-localtest@regalamiunsorriso.test}
volumes:
- mysql-data:/var/lib/mysql
- ../db:/seed:ro
@ -63,11 +70,9 @@ services:
healthcheck:
test:
- CMD
- mysqladmin
- ping
- -h
- 127.0.0.1
- -proot
- sh
- -lc
- mysql -uroot -p"$$MYSQL_ROOT_PASSWORD" -Nse "SELECT CASE WHEN EXISTS (SELECT 1 FROM pg.parm WHERE codice = 'REWRITE_URL_ENABLE' AND numero = 1) AND EXISTS (SELECT 1 FROM pg.gara LIMIT 1) THEN 1 ELSE 0 END" | grep -qx 1
interval: 10s
timeout: 5s
retries: 30

View file

@ -1,7 +1,8 @@
#!/bin/sh
set -eu
seed_dump="${LOCAL_DB_SEED_DUMP:-pg-model-seed-trimmed-20260421.sql}"
seed_dump="${LOCAL_DB_SEED_DUMP:-pg-local-purpose-seed-20260422.sql}"
overlay_dump="${LOCAL_DB_OVERLAY_DUMP:-}"
if [ ! -f "/seed/${seed_dump}" ]; then
echo "Seed dump not found: /seed/${seed_dump}" >&2
@ -10,3 +11,11 @@ fi
echo "Importing seed dump: ${seed_dump}"
mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" "${MYSQL_DATABASE}" < "/seed/${seed_dump}"
if [ -n "${overlay_dump}" ] && [ -f "/seed/${overlay_dump}" ]; then
echo "Importing seed overlay: ${overlay_dump}"
mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" "${MYSQL_DATABASE}" < "/seed/${overlay_dump}"
elif [ -n "${overlay_dump}" ]; then
echo "Seed overlay not found: /seed/${overlay_dump}" >&2
exit 1
fi

View file

@ -1,6 +1,12 @@
#!/bin/sh
set -eu
LOCAL_TEST_USER_ID="${LOCAL_TEST_USER_ID:-2}"
LOCAL_TEST_USER_LOGIN="${LOCAL_TEST_USER_LOGIN:-test}"
LOCAL_TEST_USER_PASSWORD="${LOCAL_TEST_USER_PASSWORD:-test1}"
LOCAL_TEST_USER_EMAIL="${LOCAL_TEST_USER_EMAIL:-localtest@regalamiunsorriso.test}"
LOCAL_TEST_USER_HASH_SQL="REPLACE(TO_BASE64(SHA2('${LOCAL_TEST_USER_PASSWORD}', 256)), '\\n', '')"
mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" "${MYSQL_DATABASE}" <<SQL
INSERT INTO parm (codice, descrizione, testo, numero, tipoParm, flgTipo, flgAdmin, createTmst, lastUpdTmst)
VALUES
@ -30,4 +36,79 @@ ON DUPLICATE KEY UPDATE
flgTipo = VALUES(flgTipo),
flgAdmin = VALUES(flgAdmin),
lastUpdTmst = NOW();
INSERT INTO users (
id_users,
login,
id_userProfile,
pwd,
flgValido,
dataInizioVld,
dataFineVld,
nome,
cognome,
eMail,
lang,
nominativo,
dataScadenza,
dataCreazionePwd,
dataScadenzaPwd,
oldPwd,
pwdCrypt,
nFotoVisual,
nFotoMax,
createTmst
)
SELECT
${LOCAL_TEST_USER_ID},
'${LOCAL_TEST_USER_LOGIN}',
COALESCE(
(SELECT seed_profile.id_userProfile
FROM (SELECT id_userProfile FROM users WHERE id_userProfile IS NOT NULL ORDER BY id_users LIMIT 1) AS seed_profile),
9
),
${LOCAL_TEST_USER_HASH_SQL},
'S',
NULL,
NULL,
'Local',
'Test User',
'${LOCAL_TEST_USER_EMAIL}',
'it',
'Local Test User',
'2030-12-31',
CURRENT_DATE,
'2030-12-31',
CONCAT(${LOCAL_TEST_USER_HASH_SQL}, '|'),
'${LOCAL_TEST_USER_PASSWORD}',
0,
500,
NOW()
WHERE NOT EXISTS (
SELECT 1
FROM users
WHERE id_users = ${LOCAL_TEST_USER_ID}
);
UPDATE users
SET
login = '${LOCAL_TEST_USER_LOGIN}',
pwd = ${LOCAL_TEST_USER_HASH_SQL},
flgValido = 'S',
dataInizioVld = NULL,
dataFineVld = NULL,
nome = COALESCE(NULLIF(nome, ''), 'Local'),
cognome = COALESCE(NULLIF(cognome, ''), 'Test User'),
eMail = '${LOCAL_TEST_USER_EMAIL}',
lang = COALESCE(NULLIF(lang, ''), 'it'),
nominativo = 'Local Test User',
dataScadenza = '2030-12-31',
dataCreazionePwd = CURRENT_DATE,
dataScadenzaPwd = '2030-12-31',
oldPwd = CONCAT(${LOCAL_TEST_USER_HASH_SQL}, '|'),
pwdCrypt = '${LOCAL_TEST_USER_PASSWORD}',
nFotoVisual = COALESCE(nFotoVisual, 0),
nFotoMax = COALESCE(NULLIF(nFotoMax, 0), 500),
lastUpdTmst = NOW()
WHERE id_users = ${LOCAL_TEST_USER_ID};
SQL

View file

@ -1,7 +1,7 @@
FROM tomcat:9.0.102-jdk11-temurin
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates bash perl rsync \
&& apt-get install -y --no-install-recommends ca-certificates bash default-mysql-client perl rsync \
&& rm -rf /var/lib/apt/lists/*
COPY tomcat/bootstrap-docbase.sh /usr/local/bin/bootstrap-docbase.sh

View file

@ -5,6 +5,7 @@ export CATALINA_TMPDIR=/usr/local/tomcat/temp
runtime_root="/usr/local/tomcat/webapps/ROOT"
workspace_www="/workspace/www"
workspace_rus="/workspace/rus"
rm -rf "$runtime_root"
mkdir -p "$runtime_root"
@ -22,6 +23,11 @@ done
mkdir -p "$runtime_root/WEB-INF"
cp -a "$workspace_www/WEB-INF/." "$runtime_root/WEB-INF/"
if [ -d "$workspace_rus/WEB-INF/classes" ]; then
mkdir -p "$runtime_root/WEB-INF/classes"
cp -a "$workspace_rus/WEB-INF/classes/." "$runtime_root/WEB-INF/classes/"
fi
mkdir -p /usr/local/tomcat/conf/Catalina/localhost
cat > /usr/local/tomcat/conf/Catalina/localhost/ROOT.xml <<'EOF'
<Context>
@ -31,6 +37,30 @@ EOF
/usr/local/bin/bootstrap-docbase.sh
wait_for_mysql() {
local host="${LOCAL_DB_HOST:-mysql}"
local port="${LOCAL_DB_PORT:-3306}"
local database="${LOCAL_DB_NAME:-pg}"
local user="${LOCAL_DB_USER:-root}"
local password="${LOCAL_DB_PASSWORD:-root}"
local attempts=60
for ((attempt=1; attempt<=attempts; attempt++)); do
if mysql --protocol=TCP -h "$host" -P "$port" -u"$user" -p"$password" -D "$database" -Nse "SELECT 1 FROM parm WHERE codice = 'REWRITE_URL_ENABLE' LIMIT 1" >/dev/null 2>&1; then
echo "MySQL is ready for legacy app startup"
return 0
fi
echo "Waiting for MySQL readiness ($attempt/$attempts)"
sleep 2
done
echo "MySQL did not become ready for legacy app startup" >&2
return 1
}
wait_for_mysql
patch_context_param() {
local param_name="$1"
local param_value="$2"
@ -42,6 +72,6 @@ patch_context_param database "//${LOCAL_DB_HOST:-mysql}/${LOCAL_DB_NAME:-pg}"
patch_context_param catalog "${LOCAL_DB_NAME:-pg}"
patch_context_param user "${LOCAL_DB_USER:-root}"
patch_context_param password "${LOCAL_DB_PASSWORD:-root}"
patch_context_param instance "${LOCAL_APP_INSTANCE:-local-model}"
patch_context_param instance "${LOCAL_APP_INSTANCE:-main}"
exec catalina.sh run

View file

@ -1,6 +1,9 @@
param(
[string]$SourceDump = (Join-Path $PSScriptRoot '..\db\dump-pg-202604211927.pretrim-backup.sql'),
[string]$OutputDump = (Join-Path $PSScriptRoot '..\db\dump-pg-202604211927.trimmed.sql'),
[string]$SourceDump = (Join-Path $PSScriptRoot '..\db\pg-model-seed-trimmed-20260421.sql'),
[string]$OverlayDump = (Join-Path $PSScriptRoot '..\db\pg-local-model-fixtures-overlay-20260422.sql'),
[string]$OutputDump = (Join-Path $PSScriptRoot '..\db\pg-local-purpose-seed-20260422.sql'),
[int[]]$KeepRaceIds = @(1018547, 1018557),
[int[]]$KeepUserIds = @(2),
[string]$ContainerName = 'regalami-dump-trim-mysql-temp',
[string]$VolumeName = 'regalami-dump-trim-mysql-temp-data',
[string]$DatabaseName = 'pgtrim',
@ -44,13 +47,10 @@ function Wait-ForMysqlReady {
)
for ($attempt = 0; $attempt -lt 180; $attempt++) {
$portOutput = & docker exec $Name mysql -N -B -uroot -p$Password -e "SELECT @@port;" 2>$null
if ($LASTEXITCODE -eq 0) {
$reportedPort = ($portOutput | Select-Object -First 1).Trim()
if ($reportedPort -eq '3306') {
$logs = & docker logs $Name 2>&1
if ($LASTEXITCODE -eq 0 -and ($logs -join "`n") -match 'ready for connections.*port: 3306') {
return
}
}
Start-Sleep -Seconds 2
}
@ -65,7 +65,7 @@ function Invoke-MysqlQuery {
[switch]$SkipDatabase
)
$dockerArgs = @('exec', $ContainerName, 'mysql', '-N', '-B', '-uroot', "-p$RootPassword")
$dockerArgs = @('exec', '-e', "MYSQL_PWD=$RootPassword", $ContainerName, 'mysql', '-N', '-B', '-uroot')
if (-not $SkipDatabase) {
$dockerArgs += @('-D', $DatabaseName)
}
@ -93,9 +93,18 @@ function Quote-Identifier {
return '`' + $Name.Replace('`', '``') + '`'
}
function Quote-SqlLiteral {
param([string]$Value)
return "'" + $Value.Replace("'", "''") + "'"
function ConvertTo-SqlIntList {
param(
[Parameter(Mandatory = $true)]
[int[]]$Values
)
$dedupedValues = $Values | Sort-Object -Unique
if (-not $dedupedValues -or $dedupedValues.Count -eq 0) {
throw 'At least one numeric keep id is required.'
}
return ($dedupedValues | ForEach-Object { [string]$_ }) -join ','
}
if (-not (Test-Path $SourceDump)) {
@ -105,32 +114,62 @@ if (-not (Test-Path $SourceDump)) {
$dbDirectory = Split-Path -Parent (Resolve-Path $SourceDump).Path
$sourceFileName = Split-Path -Leaf $SourceDump
$outputFileName = Split-Path -Leaf $OutputDump
$overlayResolvedPath = $null
$overlayFileName = ''
$overlayDirectory = $dbDirectory
$overlayMount = @()
if ($OverlayDump -and (Test-Path $OverlayDump)) {
$overlayResolvedPath = (Resolve-Path $OverlayDump).Path
$overlayFileName = Split-Path -Leaf $overlayResolvedPath
$overlayDirectory = Split-Path -Parent $overlayResolvedPath
if ($overlayDirectory -ne $dbDirectory) {
$overlayMount = @('-v', "${overlayDirectory}:/workspace/overlay")
}
}
$importErrorLogFileName = [System.IO.Path]::GetFileNameWithoutExtension($sourceFileName) + '.import-errors.log'
$importErrorLogPath = Join-Path $dbDirectory $importErrorLogFileName
$sourceSizeBytes = (Get-Item $SourceDump).Length
$keepRaceListSql = ConvertTo-SqlIntList -Values $KeepRaceIds
$keepUserListSql = ConvertTo-SqlIntList -Values $KeepUserIds
Write-Host "Using source dump: $SourceDump"
Write-Host "Source size: $sourceSizeBytes bytes"
Write-Host "Import error log: $importErrorLogPath"
if ($overlayResolvedPath) {
Write-Host "Using overlay dump: $overlayResolvedPath"
}
Write-Host "KeepRaceIdsRequested=$keepRaceListSql"
Write-Host "KeepUserIdsRequested=$keepUserListSql"
try {
& docker rm -f $ContainerName 1>$null 2>$null
& docker volume rm $VolumeName 1>$null 2>$null
Invoke-DockerCapture -DockerArgs @(
$dockerRunArgs = @(
'run', '-d', '--name', $ContainerName,
'-e', "MYSQL_ROOT_PASSWORD=$RootPassword",
'-e', 'MYSQL_ROOT_HOST=%',
'-v', "${dbDirectory}:/workspace/db",
'-v', "${VolumeName}:/var/lib/mysql",
'-v', "${VolumeName}:/var/lib/mysql"
)
if ($overlayMount.Count -gt 0) {
$dockerRunArgs += $overlayMount
}
$dockerRunArgs += @(
'mysql:8.4',
'--max_allowed_packet=1G',
'--net_read_timeout=600',
'--net_write_timeout=600'
) | Out-Null
)
Invoke-DockerCapture -DockerArgs $dockerRunArgs | Out-Null
Write-Host 'Temporary MySQL container started.'
Wait-ForMysqlReady -Name $ContainerName -Password $RootPassword
Write-Host 'Temporary MySQL is ready.'
Invoke-MysqlQuery -SkipDatabase -Query "DROP DATABASE IF EXISTS $DatabaseName; CREATE DATABASE $DatabaseName CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;" | Out-Null
Write-Host 'Temporary database created.'
Write-Host "Importing source dump into temporary MySQL database..."
if (Test-Path $importErrorLogPath) {
@ -141,6 +180,23 @@ try {
"mysql --force -uroot -p$RootPassword $DatabaseName < /workspace/db/$sourceFileName 2> /workspace/db/$importErrorLogFileName"
)
if ($overlayResolvedPath) {
$overlayContainerPath = if ($overlayDirectory -eq $dbDirectory) {
"/workspace/db/$overlayFileName"
}
else {
"/workspace/overlay/$overlayFileName"
}
Write-Host "Importing overlay dump into temporary MySQL database..."
Invoke-DockerQuiet -DockerArgs @(
'exec', $ContainerName, 'sh', '-lc',
"mysql --force -uroot -p$RootPassword $DatabaseName < $overlayContainerPath"
)
}
$beforeGara = [int64](Get-SqlScalar -Query 'SELECT COUNT(*) FROM gara;')
$beforePuntoFoto = [int64](Get-SqlScalar -Query 'SELECT COUNT(*) FROM punto_foto;')
$beforeFoto = [int64](Get-SqlScalar -Query 'SELECT COUNT(*) FROM foto;')
$beforeLogFoto = [int64](Get-SqlScalar -Query 'SELECT COUNT(*) FROM log_foto;')
$usersExists = (Get-SqlScalar -SkipDatabase -Query "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '$DatabaseName' AND table_name = 'users';") -eq '1'
@ -149,45 +205,19 @@ try {
$beforeUsers = [int64](Get-SqlScalar -Query 'SELECT COUNT(*) FROM users;')
}
$garaDateColumn = Get-SqlScalar -SkipDatabase -Query @"
SELECT COLUMN_NAME
FROM information_schema.columns
WHERE table_schema = '$DatabaseName'
AND table_name = 'gara'
AND COLUMN_NAME IN ('dataGaraInizio', 'dataGaraFine', 'data', 'createTmst', 'lastUpdTmst')
ORDER BY FIELD(COLUMN_NAME, 'dataGaraInizio', 'dataGaraFine', 'data', 'createTmst', 'lastUpdTmst')
LIMIT 1;
"@
if (-not $garaDateColumn) {
throw 'Could not determine race date column from gara table.'
}
$usersDateColumn = ''
if ($usersExists) {
$usersDateColumn = Get-SqlScalar -SkipDatabase -Query @"
SELECT COLUMN_NAME
FROM information_schema.columns
WHERE table_schema = '$DatabaseName'
AND table_name = 'users'
AND COLUMN_NAME IN ('dataInserimento', 'dataInizioVld', 'lastUpdTmst')
ORDER BY FIELD(COLUMN_NAME, 'dataInserimento', 'dataInizioVld', 'lastUpdTmst')
LIMIT 1;
"@
if (-not $usersDateColumn) {
$usersDateColumn = 'id_users'
}
}
$garaDateExpr = Quote-Identifier $garaDateColumn
$usersDateExpr = Quote-Identifier $usersDateColumn
Invoke-MysqlQuery -Query @"
DROP TABLE IF EXISTS keep_gara_ids;
CREATE TABLE keep_gara_ids AS
SELECT id_gara
FROM gara
ORDER BY $garaDateExpr DESC, id_gara DESC
LIMIT 10;
WHERE id_gara IN ($keepRaceListSql)
ORDER BY FIELD(id_gara, $keepRaceListSql);
DROP TABLE IF EXISTS keep_punto_foto_ids;
CREATE TABLE keep_punto_foto_ids AS
SELECT id_puntoFoto
FROM punto_foto
WHERE id_gara IN (SELECT id_gara FROM keep_gara_ids);
DROP TABLE IF EXISTS keep_foto_ids;
CREATE TABLE keep_foto_ids AS
@ -196,17 +226,50 @@ FROM foto
WHERE id_gara IN (SELECT id_gara FROM keep_gara_ids);
"@ | Out-Null
$keptGaraIds = Invoke-MysqlQuery -Query 'SELECT id_gara FROM keep_gara_ids ORDER BY id_gara;'
$missingRaceIds = @()
foreach ($requestedRaceId in ($KeepRaceIds | Sort-Object -Unique)) {
if (-not (($keptGaraIds | ForEach-Object { $_.Trim() }) -contains [string]$requestedRaceId)) {
$missingRaceIds += $requestedRaceId
}
}
if ($missingRaceIds.Count -gt 0) {
throw "One or more requested race ids are missing from the imported data: $($missingRaceIds -join ',')"
}
$cleanupStats = [System.Collections.Generic.List[string]]::new()
Invoke-MysqlQuery -Query 'DELETE FROM log_foto;' | Out-Null
$cleanupStats.Add('log_foto:deleted-all')
$garaDependentTables = Invoke-MysqlQuery -SkipDatabase -Query @"
SELECT table_name
FROM information_schema.columns
WHERE table_schema = '$DatabaseName'
AND column_name = 'id_gara'
AND table_name NOT IN ('gara', 'foto', 'punto_foto', 'keep_gara_ids', 'keep_punto_foto_ids', 'keep_foto_ids', 'keep_user_ids')
ORDER BY table_name;
"@
foreach ($tableName in $garaDependentTables) {
if (-not $tableName) {
continue
}
$trimmedTableName = $tableName.Trim()
$quotedTable = Quote-Identifier $trimmedTableName
$deletedRows = [int64](Get-SqlScalar -Query "SELECT COUNT(*) FROM $quotedTable WHERE id_gara IS NOT NULL AND id_gara NOT IN (SELECT id_gara FROM keep_gara_ids);")
if ($deletedRows -gt 0) {
Invoke-MysqlQuery -Query "DELETE FROM $quotedTable WHERE id_gara IS NOT NULL AND id_gara NOT IN (SELECT id_gara FROM keep_gara_ids);" | Out-Null
$cleanupStats.Add("$($trimmedTableName):$deletedRows")
}
}
$fotoDependentTables = Invoke-MysqlQuery -SkipDatabase -Query @"
SELECT table_name
FROM information_schema.columns
WHERE table_schema = '$DatabaseName'
AND column_name = 'id_foto'
AND table_name NOT IN ('foto', 'log_foto', 'keep_foto_ids', 'keep_gara_ids')
AND table_name NOT IN ('foto', 'log_foto', 'keep_foto_ids', 'keep_gara_ids', 'keep_punto_foto_ids', 'keep_user_ids')
ORDER BY table_name;
"@
@ -229,22 +292,46 @@ ORDER BY table_name;
$cleanupStats.Add("foto:$deletedFotoRows")
}
$deletedPuntoFotoRows = [int64](Get-SqlScalar -Query 'SELECT COUNT(*) FROM punto_foto WHERE id_gara NOT IN (SELECT id_gara FROM keep_gara_ids);')
if ($deletedPuntoFotoRows -gt 0) {
Invoke-MysqlQuery -Query 'DELETE FROM punto_foto WHERE id_gara NOT IN (SELECT id_gara FROM keep_gara_ids);' | Out-Null
$cleanupStats.Add("punto_foto:$deletedPuntoFotoRows")
}
$deletedGaraRows = [int64](Get-SqlScalar -Query 'SELECT COUNT(*) FROM gara WHERE id_gara NOT IN (SELECT id_gara FROM keep_gara_ids);')
if ($deletedGaraRows -gt 0) {
Invoke-MysqlQuery -Query 'DELETE FROM gara WHERE id_gara NOT IN (SELECT id_gara FROM keep_gara_ids);' | Out-Null
$cleanupStats.Add("gara:$deletedGaraRows")
}
$keptUserIds = @()
if ($usersExists) {
Invoke-MysqlQuery -Query @"
DROP TABLE IF EXISTS keep_user_ids;
CREATE TABLE keep_user_ids AS
SELECT id_users
FROM users
ORDER BY $usersDateExpr ASC, id_users ASC
LIMIT 5;
WHERE id_users IN ($keepUserListSql)
ORDER BY FIELD(id_users, $keepUserListSql);
"@ | Out-Null
$keptUserIds = Invoke-MysqlQuery -Query 'SELECT id_users FROM keep_user_ids ORDER BY id_users;'
$missingUserIds = @()
foreach ($requestedUserId in ($KeepUserIds | Sort-Object -Unique)) {
if (-not (($keptUserIds | ForEach-Object { $_.Trim() }) -contains [string]$requestedUserId)) {
$missingUserIds += $requestedUserId
}
}
if ($missingUserIds.Count -gt 0) {
throw "One or more requested user ids are missing from the imported data: $($missingUserIds -join ',')"
}
$usersDependentTables = Invoke-MysqlQuery -SkipDatabase -Query @"
SELECT table_name
FROM information_schema.columns
WHERE table_schema = '$DatabaseName'
AND column_name = 'id_users'
AND table_name NOT IN ('users', 'keep_user_ids')
AND table_name NOT IN ('users', 'keep_user_ids', 'keep_gara_ids', 'keep_punto_foto_ids', 'keep_foto_ids')
ORDER BY table_name;
"@
@ -268,6 +355,8 @@ ORDER BY table_name;
}
}
$afterGara = [int64](Get-SqlScalar -Query 'SELECT COUNT(*) FROM gara;')
$afterPuntoFoto = [int64](Get-SqlScalar -Query 'SELECT COUNT(*) FROM punto_foto;')
$afterFoto = [int64](Get-SqlScalar -Query 'SELECT COUNT(*) FROM foto;')
$afterLogFoto = [int64](Get-SqlScalar -Query 'SELECT COUNT(*) FROM log_foto;')
$afterUsers = 0
@ -275,26 +364,28 @@ ORDER BY table_name;
$afterUsers = [int64](Get-SqlScalar -Query 'SELECT COUNT(*) FROM users;')
}
$keptGaraIds = Invoke-MysqlQuery -Query "SELECT id_gara FROM keep_gara_ids ORDER BY $garaDateExpr DESC, id_gara DESC;"
if (Test-Path $OutputDump) {
Remove-Item $OutputDump -Force
}
Write-Host "Exporting trimmed dump..."
Write-Host "Exporting curated dump..."
Invoke-DockerQuiet -DockerArgs @(
'exec', $ContainerName, 'sh', '-lc',
"mysqldump -uroot -p$RootPassword --default-character-set=utf8mb4 --single-transaction --routines --triggers --set-gtid-purged=OFF $DatabaseName > /workspace/db/$outputFileName"
)
if (-not (Test-Path $OutputDump)) {
throw "Trimmed dump was not created: $OutputDump"
throw "Curated dump was not created: $OutputDump"
}
$outputSizeBytes = (Get-Item $OutputDump).Length
Write-Host "OriginalSizeBytes=$sourceSizeBytes"
Write-Host "TrimmedSizeBytes=$outputSizeBytes"
Write-Host "CuratedSizeBytes=$outputSizeBytes"
Write-Host "BeforeGara=$beforeGara"
Write-Host "AfterGara=$afterGara"
Write-Host "BeforePuntoFoto=$beforePuntoFoto"
Write-Host "AfterPuntoFoto=$afterPuntoFoto"
Write-Host "BeforeFoto=$beforeFoto"
Write-Host "AfterFoto=$afterFoto"
Write-Host "BeforeLogFoto=$beforeLogFoto"
@ -302,6 +393,9 @@ ORDER BY table_name;
Write-Host "BeforeUsers=$beforeUsers"
Write-Host "AfterUsers=$afterUsers"
Write-Host ("KeptGaraIds=" + (($keptGaraIds | ForEach-Object { $_.Trim() }) -join ','))
if ($usersExists) {
Write-Host ("KeptUserIds=" + (($keptUserIds | ForEach-Object { $_.Trim() }) -join ','))
}
Write-Host ("DependencyCleanup=" + (($cleanupStats | Sort-Object) -join ';'))
if (Test-Path $importErrorLogPath) {
$importErrorCount = (Get-Item $importErrorLogPath).Length

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB