From fb196cac3a35570490babf4825617f98cc9aff02 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 1 Nov 2026 10:51:46 +0000 Subject: [PATCH] Run CLI database continuity regression in standard CI --- test/README.md | 32 ++++-- test/e2e-cli/ci-tasks.json | 3 +- test/e2e-cli/helpers/docker.ts | 54 ++++++++++ test/e2e-cli/tasks.json | 3 +- test/e2e-cli/test-ci-task-contract.ts | 18 ++++ .../e2e-cli/test-fetch-database-continuity.ts | 101 +++++++++++++++--- 6 files changed, 185 insertions(+), 26 deletions(-) create mode 100644 test/e2e-cli/test-ci-task-contract.ts diff --git a/test/README.md b/test/README.md index 12d1c5eb..4930753f 100644 --- a/test/README.md +++ b/test/README.md @@ -79,21 +79,37 @@ The expected result is `PASS=7 FAIL=0`. These mirror suites use temporary local ## CLI Fetch database continuity -After building the CLI, run `node test/e2e-cli/run-task.ts test:fetch-database-continuity` -with Node.js 26 and a disposable S3-compatible service. Set +The standard CLI CI suite and direct task runner execute +`test:fetch-database-continuity` against a dedicated RustFS container. Each +scenario creates its own bucket. The test binds a uniquely named container to a +dynamic loopback port, then stops only that container. Redacted evidence is +written under the operating system's temporary directory and is not uploaded as +an artefact. + +For a local run, build the CLI and run +`node test/e2e-cli/run-task.ts test:fetch-database-continuity` with Node.js 26 +and Docker available. The managed container uses `accessKey` and `secretKey` +from the CLI `.test.env` file when present, and defaults to `minioadmin` for +both. No endpoint, bucket, or evidence-directory variables are needed. Set +`LIVESYNC_ISSUE1304_EVIDENCE_DIR` to choose another evidence directory outside +the worktree. + +To use an external S3-compatible fixture without Docker, set all four `LIVESYNC_ISSUE1304_MINIO_ENDPOINT`, `LIVESYNC_ISSUE1304_MINIO_ACCESS_KEY`, `LIVESYNC_ISSUE1304_MINIO_SECRET_KEY`, and `LIVESYNC_ISSUE1304_MINIO_BUCKET`. -The bucket must be fresh and empty for every run. Set -`LIVESYNC_ISSUE1304_EVIDENCE_DIR` to an evidence directory outside the worktree. -This test writes remote data; never point it at a production bucket. +The bucket must be fresh and empty for every run. Partial external fixture +configuration is rejected. This test writes remote data; never point it at a +production bucket. The test seeds an encrypted, path-obfuscated file with a second-generation revision, imports one Setup URI on two clients, fetches on A without `--write-settings`, and restarts A while B remains online. It checks the saved database suffix, file bytes, original revision, and absence of a duplicate root. -Set `LIVESYNC_ISSUE1304_POSITIVE_CONTROL=1` and -`LIVESYNC_ISSUE1304_POSITIVE_CONTROL_BUCKET` to a second fresh bucket to exercise -the explicit `--write-settings` variant as well. +Managed runs execute the explicit `--write-settings` control automatically in +a separate scenario bucket. For an external fixture, set +`LIVESYNC_ISSUE1304_POSITIVE_CONTROL=1` and +`LIVESYNC_ISSUE1304_POSITIVE_CONTROL_BUCKET` to a second fresh bucket to run +that control. For historical reproduction against the unmodified implementation, set `LIVESYNC_ISSUE1304_VERIFY_LEGACY_FAILURE=1`. This preserves the original diff --git a/test/e2e-cli/ci-tasks.json b/test/e2e-cli/ci-tasks.json index 08450098..078d3740 100644 --- a/test/e2e-cli/ci-tasks.json +++ b/test/e2e-cli/ci-tasks.json @@ -14,5 +14,6 @@ "test:e2e-matrix:couchdb-enc0", "test:e2e-matrix:couchdb-enc1", "test:e2e-matrix:minio-enc0", - "test:e2e-matrix:minio-enc1" + "test:e2e-matrix:minio-enc1", + "test:fetch-database-continuity" ] diff --git a/test/e2e-cli/helpers/docker.ts b/test/e2e-cli/helpers/docker.ts index 018dc226..961ba957 100644 --- a/test/e2e-cli/helpers/docker.ts +++ b/test/e2e-cli/helpers/docker.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { writeSync } from "node:fs"; import { spawnProcess } from "./process.ts"; /** @@ -577,6 +578,59 @@ export async function startMinio( await waitForMinioBucket(minioEndpoint, accessKey, secretKey, bucket); } +export type IsolatedMinioFixture = { + endpoint: string; + accessKey: string; + secretKey: string; + stop: () => Promise; +}; + +/** Start an S3 fixture owned by this caller without touching the shared test container. */ +export async function startIsolatedMinio(accessKey: string, secretKey: string): Promise { + const container = `livesync-cli-continuity-${randomUUID()}`; + console.log(`[INFO] starting isolated S3 test container: ${container}`); + let tracked = false; + const stop = async () => { + await stopAndRemoveContainer(container); + if (tracked) untrackContainer(container); + }; + + try { + await dockerOrFail( + "run", + "-d", + "--name", + container, + "-p", + "127.0.0.1::9000", + "-e", + `RUSTFS_ACCESS_KEY=${accessKey}`, + "-e", + `RUSTFS_SECRET_KEY=${secretKey}`, + "-e", + "RUSTFS_CONSOLE_ENABLE=true", + "-e", + "RUSTFS_CORS_ALLOWED_ORIGINS=*", + S3_IMAGE, + "/data" + ); + trackContainer(container); + tracked = true; + + const mapping = await dockerOrFail("port", container, "9000/tcp"); + const port = Number(mapping.trim().match(/:(\d+)$/)?.[1]); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Could not determine the isolated S3 fixture port from: ${mapping.trim()}`); + } + const endpoint = `http://127.0.0.1:${port}`; + await fetchRetry(endpoint, { method: "GET" }, 30, 1000, [403, 404]); + return { endpoint, accessKey, secretKey, stop }; + } catch (error) { + await stop(); + throw error; + } +} + // --------------------------------------------------------------------------- // P2P relay (strfry) // --------------------------------------------------------------------------- diff --git a/test/e2e-cli/tasks.json b/test/e2e-cli/tasks.json index f20da103..a0e5f460 100644 --- a/test/e2e-cli/tasks.json +++ b/test/e2e-cli/tasks.json @@ -1,7 +1,8 @@ { "test:helpers": { "test": [ - "helpers/process.test.ts" + "helpers/process.test.ts", + "test-ci-task-contract.ts" ] }, "test": { diff --git a/test/e2e-cli/test-ci-task-contract.ts b/test/e2e-cli/test-ci-task-contract.ts new file mode 100644 index 00000000..22a94565 --- /dev/null +++ b/test/e2e-cli/test-ci-task-contract.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; + +test("fetch database continuity is exercised by standard CLI CI", async () => { + const ciTasks = JSON.parse(await readFile(new URL("./ci-tasks.json", import.meta.url), "utf8")) as string[]; + const tasks = JSON.parse(await readFile(new URL("./tasks.json", import.meta.url), "utf8")) as Record< + string, + { test?: string[] } + >; + const workflow = await readFile(new URL("../../.github/workflows/cli-node-tests.yml", import.meta.url), "utf8"); + + assert(ciTasks.includes("test:helpers"), "the standard CI suite must run the task registration contract"); + assert(tasks["test:helpers"]?.test?.includes("test-ci-task-contract.ts")); + assert(ciTasks.includes("test:fetch-database-continuity"), "the continuity task must be in the standard CI suite"); + assert.deepEqual(tasks["test:fetch-database-continuity"]?.test, ["test-fetch-database-continuity.ts"]); + assert.match(workflow, /test\/e2e-cli\/ci-tasks\.json/, "the automatic CI matrix must read the authoritative task list"); +}); diff --git a/test/e2e-cli/test-fetch-database-continuity.ts b/test/e2e-cli/test-fetch-database-continuity.ts index bf66c0aa..8f38548f 100644 --- a/test/e2e-cli/test-fetch-database-continuity.ts +++ b/test/e2e-cli/test-fetch-database-continuity.ts @@ -1,9 +1,12 @@ import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { cp, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { test } from "node:test"; +import { after, before, test } from "node:test"; +import { CreateBucketCommand, S3Client } from "@aws-sdk/client-s3"; import { CLI_DIR, CLI_DIST } from "./helpers/cli.ts"; +import { startIsolatedMinio, type IsolatedMinioFixture } from "./helpers/docker.ts"; import { applyRemoteSyncSettings, generateSetupUriFromSettings, initSettingsFile } from "./helpers/settings.ts"; import { TempDir } from "./helpers/temp.ts"; import { spawnProcess, type RunningProcess } from "./helpers/process.ts"; @@ -24,20 +27,89 @@ const RESOLUTION_MARKER = "Resolving [same]: Conflicted revision has been delete const INITIAL_REPLICATION_COMPLETE = "[Daemon] Initial replication complete"; const INITIAL_REPLICATION_FAILED = "[Daemon] Initial replication failed"; const VERIFY_LEGACY_FAILURE = process.env["LIVESYNC_ISSUE1304_VERIFY_LEGACY_FAILURE"] === "1"; +const EXPLICIT_FIXTURE_VARIABLES = [ + "LIVESYNC_ISSUE1304_MINIO_ENDPOINT", + "LIVESYNC_ISSUE1304_MINIO_ACCESS_KEY", + "LIVESYNC_ISSUE1304_MINIO_SECRET_KEY", + "LIVESYNC_ISSUE1304_MINIO_BUCKET", +]; +const EXPLICIT_FIXTURE = EXPLICIT_FIXTURE_VARIABLES.some((name) => process.env[name]?.trim()); -function requiredEnv(name: string): string { - const value = process.env[name]?.trim(); - if (!value) throw new Error(`Required environment variable is missing: ${name}`); - return value; +if (EXPLICIT_FIXTURE && EXPLICIT_FIXTURE_VARIABLES.some((name) => !process.env[name]?.trim())) { + throw new Error(`External fixture mode requires all of: ${EXPLICIT_FIXTURE_VARIABLES.join(", ")}`); } -function remoteSettings(bucketOverride?: string): RemoteSettings { +let managedFixture: IsolatedMinioFixture | undefined; +if (!EXPLICIT_FIXTURE) { + before(async () => { + managedFixture = await startIsolatedMinio( + process.env["accessKey"]?.trim() ?? "minioadmin", + process.env["secretKey"]?.trim() ?? "minioadmin" + ); + }); + after(async () => { + await managedFixture?.stop(); + }); +} + +function requiredEnv(...names: string[]): string { + for (const name of names) { + const value = process.env[name]?.trim(); + if (value) return value; + } + throw new Error(`Required environment variable is missing: ${names.join(" or ")}`); +} + +function evidenceDirectory(): string { + return resolve( + process.env["LIVESYNC_ISSUE1304_EVIDENCE_DIR"]?.trim() || join(tmpdir(), "livesync-cli-issue1304-evidence") + ); +} + +function remoteSettings(): RemoteSettings { return { endpoint: requiredEnv("LIVESYNC_ISSUE1304_MINIO_ENDPOINT").replace(/\/$/, ""), accessKey: requiredEnv("LIVESYNC_ISSUE1304_MINIO_ACCESS_KEY"), secretKey: requiredEnv("LIVESYNC_ISSUE1304_MINIO_SECRET_KEY"), - bucket: bucketOverride ?? requiredEnv("LIVESYNC_ISSUE1304_MINIO_BUCKET"), - evidenceDir: resolve(requiredEnv("LIVESYNC_ISSUE1304_EVIDENCE_DIR")), + bucket: requiredEnv("LIVESYNC_ISSUE1304_MINIO_BUCKET"), + evidenceDir: evidenceDirectory(), + }; +} + +async function createManagedBucket(remote: Pick, mode: ScenarioMode): Promise { + const shortMode = mode === "reproduction" ? "repro" : "control"; + const bucket = `livesync-ci-1304-${shortMode}-${randomUUID().replaceAll("-", "")}`; + const client = new S3Client({ + endpoint: remote.endpoint, + region: "us-east-1", + forcePathStyle: true, + credentials: { accessKeyId: remote.accessKey, secretAccessKey: remote.secretKey }, + }); + try { + await client.send(new CreateBucketCommand({ Bucket: bucket })); + } finally { + client.destroy(); + } + return bucket; +} + +async function scenarioRemoteSettings(mode: ScenarioMode): Promise { + if (EXPLICIT_FIXTURE) { + const remote = remoteSettings(); + if (mode === "write-settings-control") remote.bucket = requiredEnv("LIVESYNC_ISSUE1304_POSITIVE_CONTROL_BUCKET"); + return remote; + } + + if (!managedFixture) throw new Error("The managed S3 fixture was not started"); + const remote = { + endpoint: managedFixture.endpoint, + accessKey: managedFixture.accessKey, + secretKey: managedFixture.secretKey, + }; + return { + ...remote, + bucket: await createManagedBucket(remote, mode), + evidenceDir: evidenceDirectory(), }; } @@ -265,8 +337,7 @@ async function settingsSummary(settingsPath: string): Promise { - const remote = remoteSettings(bucket); +async function runScenario(mode: ScenarioMode, remote: RemoteSettings): Promise { const evidenceRoot = remote.evidenceDir; const evidenceName = `${mode}-${Date.now()}-${process.pid}`; const evidenceDir = join(evidenceRoot, evidenceName); @@ -556,13 +627,11 @@ async function runScenario(mode: ScenarioMode, bucket: string): Promise { } test("CLI fetch: files remain after same-content duplicate root resolution", async () => { - const remote = remoteSettings(); - await runScenario("reproduction", remote.bucket); + await runScenario("reproduction", await scenarioRemoteSettings("reproduction")); }); -if (process.env["LIVESYNC_ISSUE1304_POSITIVE_CONTROL"] === "1") { +if (!EXPLICIT_FIXTURE || process.env["LIVESYNC_ISSUE1304_POSITIVE_CONTROL"] === "1") { test("CLI fetch: --write-settings keeps the selected database across daemon startup", async () => { - const controlBucket = requiredEnv("LIVESYNC_ISSUE1304_POSITIVE_CONTROL_BUCKET"); - await runScenario("write-settings-control", controlBucket); + await runScenario("write-settings-control", await scenarioRemoteSettings("write-settings-control")); }); }