From 81888f51504fdfb020de3b0240a387a33b8bbb4f Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Thu, 3 Sep 2026 17:37:58 +0800 Subject: [PATCH] fix: complete shadow v3 operations safeguards --- .../task-8-report.md | 64 ---------- .../shadow/ShadowV3OperationsPackageTest.java | 119 +++++++++++++++++- scripts/shadow-v3-preflight.cmd | 6 +- 3 files changed, 122 insertions(+), 67 deletions(-) delete mode 100644 .superpowers/sdd/2026-09-03-ft546wms-shadow-v3/task-8-report.md diff --git a/.superpowers/sdd/2026-09-03-ft546wms-shadow-v3/task-8-report.md b/.superpowers/sdd/2026-09-03-ft546wms-shadow-v3/task-8-report.md deleted file mode 100644 index f220f35..0000000 --- a/.superpowers/sdd/2026-09-03-ft546wms-shadow-v3/task-8-report.md +++ /dev/null @@ -1,64 +0,0 @@ -# Task 8 Report - -## Status - -GREEN, pending independent review. - -## RED Evidence - -- The plan's single-module command first failed dependency resolution because sibling reactor artifacts are not installed in this clean worktree. -- The ledger-approved reactor-aware command was then run: `mvn -pl ruoyi-admin -am -Dtest=ShadowV3OperationsPackageTest -Dsurefire.failIfNoSpecifiedTests=false test`. -- Result before implementation: 4 tests, 1 failure and 3 errors. All failures were caused by the missing v3 CMD, SQL, and server-AI prompt files. - -## Implementation - -- Added fail-fast CMD-only preflight, install/upgrade, confirmed uninstall, and read-only export scripts. -- Preflight verifies the exact branch/commit, clean worktree, required main ancestry, at most one WMS JVM, SQL connectivity, recognized v2/v3 manifest, and snapshot state. -- Installer accepts only `upgrade` or `clean`; the field runbook requires `upgrade` for the current v2 site. -- Uninstaller requires the literal `CONFIRM_UNINSTALL` and warns to export evidence and restore stable service first. -- Exporter writes deployment, process, environment-name-only, SQL diagnostics, comparison, metrics, inventory parity, and structured warning files. -- Export SQL contains SELECT-only diagnostics over the requested warehouse/start time and uses SQL Server 2016-compatible constructs. -- Replaced the outdated v2 runbook with the exact v3 deployment, seven-day observation, disable/restart rollback, and optional uninstall order. -- Added a self-contained prompt for the server AI that authorizes export only and forbids deployment/database changes. - -## GREEN Evidence - -- Reactor-aware operations command: 5 tests, 0 failures, 0 errors, `BUILD SUCCESS`. -- Plan-mandated no-argument CMD parser checks passed for preflight and uninstall. -- Additional no-argument checks passed for install and export. -- Invalid-mode/confirmation/path protection checks passed for all four scripts without SQL execution. - -## Files Changed - -- `scripts/shadow-v3-preflight.cmd` -- `scripts/shadow-v3-install.cmd` -- `scripts/shadow-v3-uninstall.cmd` -- `scripts/shadow-v3-export.cmd` -- `scripts/shadow-v3-export.sql` -- `doc/algorithm-shadow/README.md` -- `doc/algorithm-shadow/SHADOW_V3_SERVER_AI_EXPORT_PROMPT.md` -- `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowV3OperationsPackageTest.java` - -## Concerns - -- The SQL package was not executed against a live production or disposable SQL Server; production execution remains explicitly out of scope. -- Existing unrelated Lombok superclass and JVM class-data-sharing warnings remain unchanged. - -## Fix Round 1 - -### RED Evidence - -Independent review reproduced six gaps: v3 snapshot state was display-only; repeat exports could overwrite evidence; environment capture did not validate its output; log collection conflated no matches with source failures; tests lacked executable CMD failure coverage; and JVM matching did not explicitly handle no-match status. - -### Changed Behavior - -- Preflight preserves clean-install and v2 eligibility, while v3 requires a published warehouse `01` snapshot with positive generation, synchronization/signature, no error, and no recent heartbeat/claim. -- Export refuses an existing non-empty output directory before writing, validates environment evidence, requires a log candidate, and treats `findstr` code 1 as an empty valid warning file while failing on code 2+. - -### Verification - -`D:/BDevTools/maven/apache-maven-3.9.11/bin/mvn.cmd -pl ruoyi-admin -am -Dtest=ShadowV3OperationsPackageTest '-Dsurefire.failIfNoSpecifiedTests=false' test` -> `BUILD SUCCESS`, 5 tests, 0 failures, 0 errors. - -### Concerns - -Executable CMD integration tests with stub SQL/JVM tools remain limited by the current Java test harness; live SQL Server 2016 validation remains out of scope. diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowV3OperationsPackageTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowV3OperationsPackageTest.java index 0c09e3d..479c620 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowV3OperationsPackageTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowV3OperationsPackageTest.java @@ -1,18 +1,25 @@ package com.ruoyi.algorithm.shadow; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.io.TempDir; +import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Locale; +import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; class ShadowV3OperationsPackageTest { private final Path root = projectRoot(); + @TempDir + Path tempDir; + @Test void packageContainsAllCmdOnlyOperationsFiles() { assertThat(root.resolve("scripts/shadow-v3-preflight.cmd")).isRegularFile(); @@ -37,6 +44,11 @@ class ShadowV3OperationsPackageTest { "jps -lv", "sqlcmd", "shadow_snapshot_state", + "snapshot_generation>0", + "snapshot_status='PUBLISHED'", + "DATEDIFF(SECOND,synchronized_at,SYSUTCDATETIME()) BETWEEN 0 AND 300", + "alg.shadow_instance_heartbeat", + "DATEDIFF(SECOND,last_seen_at,SYSUTCDATETIME()) BETWEEN 0 AND 120", "FT546WMS_SHADOW_ONLY", "2026-09-03-3", "exit /b 1"); @@ -78,7 +90,10 @@ class ShadowV3OperationsPackageTest { "shadow-v3-export.sql", "sqlcmd", "-E", "-b", "-r1", "FORTUCKY_SHADOW_", - "shadow_failure"); + "shadow_failure", + "OUTPUT_DIR already exists and is not empty", + "environment-variable-names.txt", + "if errorlevel 2"); assertThat(exportSql).contains( "$(warehouseid)", "$(startutc)", @@ -128,6 +143,108 @@ class ShadowV3OperationsPackageTest { "EXPORT_OK"); } + @Test + void cmdScriptsHandleQuotedPathsAndPropagateToolFailures() throws Exception { + Assumptions.assumeTrue(isWindows(), "CMD behavior is verified on Windows"); + Path fixture = Files.createDirectories(tempDir.resolve("fixture with spaces")); + Path repo = fixture.resolve("repo with spaces"); + cloneRepository(repo); + Path logDir = Files.createDirectories(fixture.resolve("logs with spaces")); + Files.writeString(logDir.resolve("application log.txt"), "normal log line\n", StandardCharsets.UTF_8); + Path stubBin = writeSqlcmdFailureStub(fixture); + + CmdResult preflight = runCmd(root.resolve("scripts/shadow-v3-preflight.cmd"), stubBin, + repo.toString(), gitHead(repo), + "SQL SERVER WITH SPACE", "FT546 WMS"); + assertThat(preflight.exitCode()).isEqualTo(1); + assertThat(preflight.output()).contains("SQL connectivity or shadow package validation failed"); + + Path output = fixture.resolve("new export with spaces"); + CmdResult export = runCmd(root.resolve("scripts/shadow-v3-export.cmd"), stubBin, + repo.toString(), "SQL SERVER WITH SPACE", "FT546 WMS", + "2026-09-03T00:00:00Z", output.toString(), logDir.toString()); + assertThat(export.exitCode()).isEqualTo(1); + assertThat(export.output()).contains("read-only SQL export failed"); + } + + @Test + void exportRefusesToOverwriteExistingEvidence() throws Exception { + Assumptions.assumeTrue(isWindows(), "CMD behavior is verified on Windows"); + Path fixture = Files.createDirectories(tempDir.resolve("repeat export fixture")); + Path repo = Files.createDirectories(fixture.resolve("repo")); + Files.createDirectory(repo.resolve(".git")); + Path logDir = Files.createDirectories(fixture.resolve("logs")); + Files.writeString(logDir.resolve("application.log"), "normal log line\n", StandardCharsets.UTF_8); + Path output = Files.createDirectories(fixture.resolve("existing evidence")); + Path marker = output.resolve("do-not-overwrite.txt"); + Files.writeString(marker, "preserve me", StandardCharsets.UTF_8); + Path stubBin = writeSqlcmdFailureStub(fixture); + + CmdResult result = runCmd(root.resolve("scripts/shadow-v3-export.cmd"), stubBin, + repo.toString(), "server", "database", "2026-09-03T00:00:00Z", + output.toString(), logDir.toString()); + + assertThat(result.exitCode()).isEqualTo(1); + assertThat(result.output()).contains("OUTPUT_DIR already exists and is not empty"); + assertThat(Files.readString(marker, StandardCharsets.UTF_8)).isEqualTo("preserve me"); + assertThat(output.resolve("deployment.txt")).doesNotExist(); + } + + private Path writeSqlcmdFailureStub(Path fixture) throws IOException { + Path bin = Files.createDirectories(fixture.resolve("stub tools")); + Path whereExecutable = Path.of(System.getenv("SystemRoot"), "System32", "where.exe"); + Files.copy(whereExecutable, bin.resolve("sqlcmd.exe")); + return bin; + } + + private void cloneRepository(Path destination) throws Exception { + CmdResult result = runProcess(new ProcessBuilder( + "git", "clone", "--quiet", root.toString(), destination.toString()), 60); + assertThat(result.exitCode()).as(result.output()).isZero(); + } + + private String gitHead(Path repository) throws Exception { + CmdResult result = runProcess(new ProcessBuilder( + "git", "-C", repository.toString(), "rev-parse", "HEAD"), 15); + assertThat(result.exitCode()).as(result.output()).isZero(); + return result.output().trim(); + } + + private CmdResult runCmd(Path script, Path stubBin, String... arguments) throws Exception { + StringBuilder command = new StringBuilder("call ").append(quote(script.toString())); + for (String argument : arguments) { + command.append(' ').append(quote(argument)); + } + ProcessBuilder builder = new ProcessBuilder("cmd.exe", "/d", "/c", command.toString()); + String inheritedPath = builder.environment().getOrDefault("PATH", ""); + builder.environment().put("PATH", stubBin + File.pathSeparator + inheritedPath); + builder.environment().put("FORTUCKY_ALGORITHM_WAREHOUSE_ID", "01"); + builder.redirectErrorStream(true); + return runProcess(builder, 15); + } + + private CmdResult runProcess(ProcessBuilder builder, int timeoutSeconds) throws Exception { + builder.redirectErrorStream(true); + Process process = builder.start(); + boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + } + assertThat(finished).as("CMD script completed").isTrue(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + return new CmdResult(process.exitValue(), output); + } + + private static String quote(String value) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + + private static boolean isWindows() { + return System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("windows"); + } + + private record CmdResult(int exitCode, String output) { } + private String text(String relativePath) throws IOException { return Files.readString(root.resolve(relativePath), StandardCharsets.UTF_8); } diff --git a/scripts/shadow-v3-preflight.cmd b/scripts/shadow-v3-preflight.cmd index a9f6817..2d3edb4 100644 --- a/scripts/shadow-v3-preflight.cmd +++ b/scripts/shadow-v3-preflight.cmd @@ -44,10 +44,12 @@ if errorlevel 1 (call :fail "required main commit is not an ancestor" & exit /b popd >nul set "WMS_PROCESS_COUNT=0" -for /f %%I in ('jps -lv 2^>nul ^| findstr /I /C:"com.ruoyi.RuoYiApplication" /C:"FT546WMS" ^| find /C /V ""') do set "WMS_PROCESS_COUNT=%%I" +jps -lv >nul 2>nul +if errorlevel 1 (call :fail "jps could not inspect running JVMs" & exit /b 1) +for /f %%I in ('jps -lv 2^>nul ^| findstr /R /I /C:"^[0-9][0-9]* com\.ruoyi\.RuoYiApplication" ^| find /C /V ""') do set "WMS_PROCESS_COUNT=%%I" if %WMS_PROCESS_COUNT% GTR 1 (call :fail "more than one FT546WMS JVM is running" & exit /b 1) -set "PREFLIGHT_SQL=SET NOCOUNT ON; IF SCHEMA_ID(N'alg') IS NULL SELECT N'CLEAN_INSTALL_ELIGIBLE' AS package_state; ELSE BEGIN IF OBJECT_ID(N'alg.shadow_package_manifest', N'U') IS NULL THROW 51050, 'shadow manifest is missing', 1; IF NOT EXISTS (SELECT 1 FROM alg.shadow_package_manifest WHERE package_id='FT546WMS_SHADOW_ONLY' AND package_version IN ('2026-08-12-1','2026-09-03-3')) THROW 51051, 'shadow manifest is not a supported version', 1; IF EXISTS (SELECT 1 FROM alg.shadow_package_manifest WHERE package_id='FT546WMS_SHADOW_ONLY' AND package_version='2026-09-03-3') BEGIN IF OBJECT_ID(N'alg.shadow_snapshot_state', N'U') IS NULL THROW 51052, 'snapshot state is missing', 1; IF NOT EXISTS (SELECT 1 FROM alg.shadow_snapshot_state WHERE warehouse_id='01' AND snapshot_generation>0 AND snapshot_status='PUBLISHED' AND synchronized_at IS NOT NULL AND source_signature IS NOT NULL AND last_error IS NULL) THROW 51053, 'published warehouse 01 snapshot is unusable', 1; IF OBJECT_ID(N'alg.shadow_instance_heartbeat', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM alg.shadow_instance_heartbeat WHERE warehouse_id='01' AND ((last_seen_at IS NOT NULL AND DATEDIFF(SECOND,last_seen_at,SYSUTCDATETIME()) BETWEEN 0 AND 60) OR (claimed_at IS NOT NULL AND DATEDIFF(SECOND,claimed_at,SYSUTCDATETIME()) BETWEEN 0 AND 60))) THROW 51054, 'a recent shadow instance heartbeat or claim exists', 1; END SELECT package_id, package_version, installed_at FROM alg.shadow_package_manifest; END" +set "PREFLIGHT_SQL=SET NOCOUNT ON; IF SCHEMA_ID(N'alg') IS NULL SELECT N'CLEAN_INSTALL_ELIGIBLE' AS package_state; ELSE BEGIN IF OBJECT_ID(N'alg.shadow_package_manifest', N'U') IS NULL BEGIN; THROW 51050, 'shadow manifest is missing', 1; END; IF NOT EXISTS (SELECT 1 FROM alg.shadow_package_manifest WHERE package_id='FT546WMS_SHADOW_ONLY' AND package_version IN ('2026-08-12-1','2026-09-03-3')) BEGIN; THROW 51051, 'shadow manifest is not a supported version', 1; END; IF EXISTS (SELECT 1 FROM alg.shadow_package_manifest WHERE package_id='FT546WMS_SHADOW_ONLY' AND package_version='2026-09-03-3') BEGIN IF OBJECT_ID(N'alg.shadow_snapshot_state', N'U') IS NULL BEGIN; THROW 51052, 'snapshot state is missing', 1; END; IF OBJECT_ID(N'alg.shadow_instance_heartbeat', N'U') IS NULL BEGIN; THROW 51055, 'shadow instance heartbeat is missing', 1; END; IF NOT EXISTS (SELECT 1 FROM alg.shadow_snapshot_state WHERE warehouse_id='01' AND snapshot_generation>0 AND snapshot_status='PUBLISHED' AND synchronized_at IS NOT NULL AND DATEDIFF(SECOND,synchronized_at,SYSUTCDATETIME()) BETWEEN 0 AND 300 AND source_signature IS NOT NULL AND last_error IS NULL) BEGIN; THROW 51053, 'published warehouse 01 snapshot is unusable', 1; END; IF EXISTS (SELECT 1 FROM alg.shadow_instance_heartbeat WHERE warehouse_id='01' AND ((DATEDIFF(SECOND,last_seen_at,SYSUTCDATETIME()) BETWEEN 0 AND 120) OR (DATEDIFF(SECOND,claimed_at,SYSUTCDATETIME()) BETWEEN 0 AND 120))) BEGIN; THROW 51054, 'a recent shadow instance heartbeat or claim exists', 1; END; END; SELECT package_id, package_version, installed_at FROM alg.shadow_package_manifest; IF OBJECT_ID(N'alg.shadow_snapshot_state', N'U') IS NOT NULL SELECT warehouse_id, snapshot_generation, snapshot_status, synchronized_at, CONVERT(varchar(64), source_signature, 2) AS source_signature, last_error FROM alg.shadow_snapshot_state; END" sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -Q "%PREFLIGHT_SQL%" if errorlevel 1 (call :fail "SQL connectivity or shadow package validation failed" & exit /b 1)