fix: complete shadow v3 operations safeguards

This commit is contained in:
zgw
2026-09-03 17:37:58 +08:00
parent 22cf1f9a03
commit 81888f5150
3 changed files with 122 additions and 67 deletions

View File

@@ -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.

View File

@@ -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);
}

View File

@@ -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)