test(ft546): verify read-only shadow operation

This commit is contained in:
zgw
2026-08-14 19:08:01 +08:00
parent 240f5b0808
commit 01bdf37b06
22 changed files with 2377 additions and 6 deletions

View File

@@ -13,6 +13,12 @@
若依是一套全部开源的快速开发平台,毫无保留给个人及企业免费使用。
## FT546 只读影子调度
FT546 新调度算法通过独立 `ruoyi-wcs-shadow` 进程进行只读影子验证。`LEGACY` 仍是唯一生产调度者和 PLC/WMS 写入者;当前工作不授权 `STANDARD` 切换或生产上线。
现场准备、预检、精确启停、立即停止条件和证据导出见 [FT546 实时只读影子运行手册](docs/runbooks/ft546-realtime-shadow-runbook.md)。
* 前端采用Vue、Element UI。
* 后端采用Spring Boot、Spring Security、Redis & Jwt。
* 权限认证使用Jwt,支持多终端认证系统。

View File

@@ -0,0 +1,252 @@
# FT546 实时只读影子运行手册
## 1. 适用范围
本手册只用于运行独立的 `ruoyi-wcs-shadow` 进程,采集新调度算法的只读影子证据。
- 现场 `LEGACY` WCS 始终是唯一生产调度者、PLC 写入者和 WMS 回调者。
- 本阶段不切换 `STANDARD`,不允许 shadow 连接 PLC/WMS,也不允许写生产业务表。
- 停止或移除 shadow 进程就是本阶段的回滚;不得停止或切换稳定 WCS。
- 本地自动化测试不能替代 SQL Server、网络隔离或现场观察门禁。
## 2. 当前验证边界
| 门禁 | 当前状态 | 允许的结论 |
| --- | --- | --- |
| Java 单元、Spring 上下文、H2 E2E | 可在本地执行 | 代码级只读链路和协议证据可验证 |
| PowerShell 脚本生命周期 | 可用无网络 sleeper jar 本地执行 | 确认、哈希、PID 精确启停和离线导出逻辑可验证 |
| SQL Server 集成 | `NOT_RUN` | 不得宣称数据库集成或现场就绪 |
| PLC/WMS 网络 deny | `NOT_RUN` | 必须由目标 shadow 主机的新鲜审计报告证明 |
| HIL 与现场影子窗口 | `NOT_RUN` | 不得部署或开始采集 |
`application-shadow-local.yml` 是外部配置模板,不是可直接运行的 H2 profile。H2 仍是 test-scope 依赖,且测试资源不会进入打包 jar;没有审批过的独立 SQL Server 环境时,不运行 jar 本地 smoke。
当前 `shadow_hold_history` 能跨重启恢复 wait edge 和连续 HOLD 计数,测试覆盖第三次相同 HOLD 后计数为 3。生产调度内核尚无消费该计数并触发 reroute 的策略,因此不得把“计数恢复”报告成“第三次 HOLD 已重规划”。该语义应在取得影子证据并明确路线/阻塞资源状态后单独设计。
## 3. 前置条件
开始任何现场操作前,逐项确认:
1. 稳定 WCS 健康,实际制品、配置、PID、命令行和监听端口可读取。
2. source 登录只能读取 `shadow_ro.*` 批准视图,无生产表 `INSERT`、`UPDATE`、`DELETE`、`EXECUTE`、`ALTER`、`CONTROL` 或所有权权限。
3. evidence 数据库与 source 数据库物理分离,evidence 登录只在 evidence 数据库具备所需写权限。
4. shadow 部署目录、日志目录、端口和 PID 文件均与稳定 WCS 分离。
5. shadow 主机到 PLC 写端点和生产 WMS 回调端点已在网络层拒绝,并生成不超过 60 分钟的 JSON 审计报告。
6. 已约定生产 DB 延迟、shadow CPU/内存、source lag 和 evidence 磁盘阈值。
7. 已准备立即停止 shadow 的值守人员;不安排任何稳定 WCS 切换动作。
权限报告最小结构:
```json
{
"generatedAtUtc": "2026-08-14T10:00:00Z",
"status": "PASS",
"source": {
"server": "source-host",
"database": "FT546-wcs",
"login": "ft546_shadow_reader",
"approvedViewsReadable": true,
"productionWritePermissionCount": 0
},
"evidence": {
"server": "evidence-host",
"database": "FT546-shadow",
"login": "ft546_shadow_writer",
"requiredWritesGranted": true
}
}
```
网络 deny 报告最小结构:
```json
{
"generatedAtUtc": "2026-08-14T10:00:00Z",
"status": "PASS",
"plcEgressDenied": true,
"wmsEgressDenied": true
}
```
报告必须来自已批准环境的实际检查。不得手工伪造 `PASS`。
## 4. 构建与本地验证
在隔离工作树执行:
```powershell
mvn clean test
mvn -pl ruoyi-wcs-shadow -am -DskipTests package
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass `
-File .\tools\shadow\tests\Test-Ft546ShadowScripts.ps1
```
打包后确认 shadow jar 不含 H2 或测试 profile:
```powershell
jar tf .\ruoyi-wcs-shadow\target\ruoyi-wcs-shadow-3.8.9.jar |
Select-String 'h2|application-shadow-test|application-shadow-local'
```
预期没有匹配项。该检查证明打包边界,不证明现场可运行。
## 5. 捕获稳定 WCS 基线
使用现场实际路径和 PID,不用 Git 分支或远端引用代替已部署制品:
```powershell
.\tools\shadow\Capture-StableWcsManifest.ps1 `
-StableArtifact 'D:\wcs-stable\ruoyi-admin.jar' `
-StablePid 1234 `
-Configuration 'D:\wcs-stable\application.yml' `
-OutputJson 'D:\shadow-evidence\stable-manifest.json'
```
归档输出 JSON。确认其中的 artifact/config SHA-256、PID、启动时间、命令行和监听端口与现场一致。
## 6. 准备 shadow 目录和配置
将打包的 shadow jar 和一份外部 site 配置放在独立部署目录。配置只允许以下网络相关内容:
- loopback 状态端点;
- source SQL Server 只读连接;
- 独立 evidence SQL Server 写连接。
配置不得包含 PLC IP/port 或 WMS URL。密码使用受控环境变量或主机密钥设施,不写入 Git、预检 JSON、PID 文件或命令行。
`Start-Ft546Shadow.ps1` 会从成功预检向进程注入 artifact/config SHA-256、source server/database、loopback address/port 和磁盘阈值,供应用内 startup gate 复核。
## 7. 运行预检
```powershell
.\tools\shadow\Test-Ft546ShadowPreflight.ps1 `
-StableManifest 'D:\shadow-evidence\stable-manifest.json' `
-ShadowArtifact 'D:\ft546-shadow\ruoyi-wcs-shadow.jar' `
-Config 'D:\ft546-shadow\application-shadow-site.yml' `
-DeploymentDirectory 'D:\ft546-shadow' `
-PermissionReport 'D:\shadow-evidence\permission-report.json' `
-NetworkDenyReport 'D:\shadow-evidence\network-deny-report.json' `
-StatusUri 'http://127.0.0.1:18091/shadow/status' `
-MinimumFreeBytes 10737418240 `
-OutputJson 'D:\shadow-evidence\shadow-preflight.json'
```
预检会失败关闭并留下 JSON,检查内容包括:
- 稳定 PID 健康且 artifact/config 哈希未变;
- shadow jar/config 位于独立部署目录;
- YAML 中 source/evidence server、database、username 与权限报告一致;
- source/evidence 数据库不同;
- 权限和网络 deny 报告为新鲜 `PASS`;
- 状态端点为 loopback,端口不与稳定 WCS 冲突;
- 配置无 PLC/WMS 端点;
- evidence 磁盘空间达到阈值;
- shadow 部署目录中不存在当前 `shadow.pid.json`。即使其中记录的进程已退出,陈旧 PID 文件也会使预检失败;必须先核验并按停机/归档流程处理,禁止覆盖后启动。
任何一项失败都不得启动。
### 7.1 处理陈旧 PID 记录
不得删除或覆盖 `shadow.pid.json`。先读取其中的 `pid`:如果该 PID 仍存在,只能执行第 10 节的 `Stop-Ft546Shadow.ps1`,由脚本校验启动时间、Java 可执行文件、shadow jar 哈希和 `-jar` 命令行;校验不一致时停止操作并升级处理。
只有在 `Get-Process` 连续两次(间隔至少 5 秒)均确认该 PID 不存在后,才可由两名复核人员将原记录改名为 `shadow.stale.<UTC timestamp>.json`。变更单必须保留原记录 SHA-256、两次查询输出、归档路径、操作者和复核人。归档后重新执行完整预检,旧的预检 JSON 不得复用。
## 8. 启动与初始观察
只有经过双人复核的成功预检才能启动:
```powershell
$started = .\tools\shadow\Start-Ft546Shadow.ps1 `
-ConfirmReadOnlyShadow `
-PreflightJson 'D:\shadow-evidence\shadow-preflight.json'
$started
Invoke-RestMethod 'http://127.0.0.1:18091/shadow/status'
```
启动后立即重新核对:
1. 稳定 PID、制品哈希、健康状态和 PLC writer 所有权均未变化。
2. shadow PID 与稳定 PID 不同;PID 文件位于 shadow 部署目录。
3. shadow 只监听预检中的 loopback 端口。
4. source 审计只有批准视图的 `SELECT`;生产业务表写审计为零。
5. PLC/WMS 网络监控没有 shadow 连接尝试。
先以低频轮询观察 15 分钟。只有稳定 WCS 指标不变、source 查询延迟在阈值内、source lag 正常、evidence 持续增长且磁盘安全时,才可延长观察窗口。
`STARTING` 表示尚无完整 cycle;`READY` 表示最近 cycle 完成;`DEGRADED` 必须按 reason code 调查。证据存储失败或碰撞会永久停止该进程继续读取,需停止并保留现场。
## 9. 立即停止条件
出现任一条件,立即停止 shadow,不等待当前观察窗口结束:
- 稳定 WCS 健康、CPU、内存、线程、PID 或 PLC writer 所有权变化;
- 生产 DB 延迟超过约定阈值,或出现锁等待/连接池压力;
- source 权限、数据库身份或批准视图发生变化;
- source lag 超出事实新鲜度,或连续 query timeout;
- 任意 PLC/WMS 连接尝试或任意生产表写审计;
- evidence 磁盘低于阈值、写失败、哈希碰撞或 evidence backlog 异常;
- shadow CPU/内存超过约定上限;
- 状态为 `DEGRADED` 且原因不能立即解释为短暂 source 故障。
## 10. 精确停止与回滚
```powershell
.\tools\shadow\Stop-Ft546Shadow.ps1 `
-PidFile 'D:\ft546-shadow\shadow.pid.json'
```
停止脚本只接受该 PID 文件记录的进程,并逐项验证 PID、启动时间、Java 可执行文件、shadow jar 哈希和 `-jar` 命令行。任何不一致都拒绝停止,不会按 `java` 进程名批量查杀。成功后 PID 记录改名归档为 `shadow.stopped.<timestamp>.json`。
回滚完成条件:shadow PID 不存在,稳定 PID/健康/PLC 所有权保持原值,网络与数据库审计无 shadow 副作用。稳定 WCS 从未停止或切换。
## 11. 导出证据
现场 SQL Server 模式使用集成认证,或把 SQL 密码短暂放入当前进程环境变量 `FT546_SHADOW_EVIDENCE_PASSWORD`:
```powershell
.\tools\shadow\Export-Ft546ShadowEvidence.ps1 `
-EvidenceServer 'evidence-host' `
-EvidenceDatabase 'FT546-shadow' `
-EvidenceUsername 'ft546_shadow_writer' `
-RunId '00000000-0000-0000-0000-000000000000' `
-OutputDirectory 'D:\shadow-evidence\export-20260814'
```
离线复核模式接受已审批工具生成的快照目录:
```powershell
.\tools\shadow\Export-Ft546ShadowEvidence.ps1 `
-SnapshotDirectory 'D:\shadow-evidence\approved-snapshot' `
-RunId '00000000-0000-0000-0000-000000000000' `
-OutputDirectory 'D:\shadow-evidence\export-20260814'
```
输出固定包含:
- `cycles.csv`、`cycles.json`;
- `protocol-differences.csv`;
- `algorithm-differences.csv`;
- `insufficient-facts.csv`;
- `startup-checks.json`;
- `manifest.json`、`manifest.sha256`。
校验:
```powershell
$expected = (Get-Content 'D:\shadow-evidence\export-20260814\manifest.sha256').Split()[0]
$actual = (Get-FileHash 'D:\shadow-evidence\export-20260814\manifest.json' -Algorithm SHA256).Hash.ToLowerInvariant()
if ($expected -ne $actual) { throw 'EVIDENCE_MANIFEST_HASH_MISMATCH' }
```
将稳定 manifest、preflight JSON、权限报告、网络 deny 报告、启动/停止 PID 记录、状态响应、现场监控截图和导出目录作为同一批次归档。
## 12. 禁止事项
- 不在生产数据库内创建 evidence schema 代替独立 evidence 数据库。
- 不给 source 登录任何生产写权限。
- 不把 `ruoyi-admin` 以 `SHADOW` 模式启动。
- 不允许 shadow 配置 PLC/WMS endpoint。
- 不用宽泛 Java 进程查杀命令代替 `Stop-Ft546Shadow.ps1`。
- 不因为本地/H2 测试通过就跳过 SQL Server、网络或现场门禁。
- 不把本阶段结果解释为 `STANDARD` 上线许可。

View File

@@ -10,11 +10,13 @@ import com.ruoyi.shadow.evidence.ShadowEvidenceStorageHealth;
import com.ruoyi.shadow.evidence.ShadowRunEvidence;
import com.ruoyi.shadow.facts.ShadowFactBatchValidator;
import com.ruoyi.shadow.kernel.Ft546ShadowCycle;
import com.ruoyi.shadow.kernel.Ft546ShadowScheduler;
import com.ruoyi.shadow.kernel.JdbcShadowTrafficStateRepository;
import com.ruoyi.shadow.kernel.RecordingFt546CommandGateway;
import com.ruoyi.shadow.kernel.ShadowCommandIntentFactory;
import com.ruoyi.shadow.kernel.ShadowDispatchSnapshotAssembler;
import com.ruoyi.shadow.startup.ShadowStartupReport;
import com.ruoyi.shadow.facts.SqlServerShadowFactReader;
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy;
@@ -176,4 +178,14 @@ public class ShadowKernelConfiguration {
shadowEvidenceRepository, shadowCycleEvidenceCollector,
shadowStartupReport);
}
@Bean(destroyMethod = "close")
public Ft546ShadowScheduler ft546ShadowScheduler(
SqlServerShadowFactReader factReader,
Ft546ShadowCycle shadowCycle,
ShadowStartupReport shadowStartupReport,
@Qualifier("shadowClock") Clock clock) {
return new Ft546ShadowScheduler(factReader, shadowCycle,
shadowStartupReport, clock);
}
}

View File

@@ -0,0 +1,211 @@
package com.ruoyi.shadow.kernel;
import com.ruoyi.shadow.facts.ShadowFactBatch;
import com.ruoyi.shadow.facts.ShadowFactReadException;
import com.ruoyi.shadow.facts.ShadowQueueFact;
import com.ruoyi.shadow.facts.ShadowReadCursor;
import com.ruoyi.shadow.facts.SqlServerShadowFactReader;
import com.ruoyi.shadow.startup.ShadowStartupReport;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
public final class Ft546ShadowScheduler implements AutoCloseable {
private static final Logger LOGGER =
LoggerFactory.getLogger(Ft546ShadowScheduler.class);
private static final Duration CYCLE_DEADLINE = Duration.ofSeconds(4);
private static final Duration CRASH_BACKOFF = Duration.ofSeconds(1);
private static final String FINISH_QUEUE_VIEW =
"shadow_ro.v_wcs_srgv_queue_finish";
private final SqlServerShadowFactReader factReader;
private final Ft546ShadowCycle shadowCycle;
private final ShadowStartupReport statusReport;
private final Clock clock;
private final Duration cycleDeadline;
private final ExecutorService cycleExecutor;
private final AtomicBoolean inFlight = new AtomicBoolean();
private final AtomicBoolean permanentlyStopped = new AtomicBoolean();
private volatile ShadowReadCursor cursor = ShadowReadCursor.initial();
private volatile Instant nextAttemptAt = Instant.EPOCH;
public Ft546ShadowScheduler(SqlServerShadowFactReader factReader,
Ft546ShadowCycle shadowCycle,
ShadowStartupReport statusReport,
Clock clock) {
this(factReader, shadowCycle, statusReport, clock, CYCLE_DEADLINE);
}
public Ft546ShadowScheduler(SqlServerShadowFactReader factReader,
Ft546ShadowCycle shadowCycle,
ShadowStartupReport statusReport,
Clock clock,
Duration cycleDeadline) {
this.factReader = Objects.requireNonNull(factReader, "factReader");
this.shadowCycle = Objects.requireNonNull(shadowCycle, "shadowCycle");
this.statusReport = Objects.requireNonNull(statusReport,
"statusReport");
this.clock = Objects.requireNonNull(clock, "clock");
this.cycleDeadline = requirePositive(cycleDeadline);
this.cycleExecutor = Executors.newSingleThreadExecutor(
shadowCycleThreadFactory());
}
@Scheduled(
initialDelayString = "${shadow.poll.initial-delay-ms:5000}",
fixedDelayString = "${shadow.poll.fixed-delay-ms:5000}")
public void runScheduledCycle() {
runOnce();
}
public boolean runOnce() {
Instant now = clock.instant();
if (permanentlyStopped.get() || now.isBefore(nextAttemptAt)
|| !inFlight.compareAndSet(false, true)) {
return false;
}
Future<CycleAttempt> future = cycleExecutor.submit(
() -> executeCycle(now));
try {
CycleAttempt attempt = future.get(cycleDeadline.toMillis(),
TimeUnit.MILLISECONDS);
if ("DEGRADED".equals(attempt.result.getStatusCode())) {
permanentlyStopped.set(true);
statusReport.markPermanentlyDegraded(
attempt.result.getReasonCode(), clock.instant());
LOGGER.error("FT546 shadow collection stopped: {}",
attempt.result.getReasonCode());
} else {
cursor = advanceFinishCursor(cursor, attempt.batch);
}
nextAttemptAt = Instant.EPOCH;
return true;
} catch (TimeoutException timeout) {
future.cancel(true);
return stopPermanently("SHADOW_CYCLE_TIMEOUT", timeout);
} catch (InterruptedException interrupted) {
future.cancel(true);
Thread.currentThread().interrupt();
return stopPermanently("SHADOW_CYCLE_INTERRUPTED", interrupted);
} catch (ExecutionException wrapped) {
return handleFailure(wrapped.getCause());
} finally {
inFlight.set(false);
}
}
public boolean isPermanentlyStopped() {
return permanentlyStopped.get();
}
public Duration getRemainingBackoff() {
Duration remaining = Duration.between(clock.instant(), nextAttemptAt);
return remaining.isNegative() ? Duration.ZERO : remaining;
}
@Override
public void close() {
permanentlyStopped.set(true);
cycleExecutor.shutdownNow();
}
private CycleAttempt executeCycle(Instant startedAt) {
ShadowFactBatch batch = factReader.read(cursor,
startedAt.plus(cycleDeadline));
return new CycleAttempt(batch, shadowCycle.run(batch, clock.instant()));
}
private boolean handleFailure(Throwable failure) {
if (failure instanceof ShadowFactReadException) {
ShadowFactReadException readFailure =
(ShadowFactReadException) failure;
Instant failedAt = clock.instant();
statusReport.markDegraded(readFailure.getReasonCode(), failedAt);
nextAttemptAt = failedAt.plus(readFailure.getRetryAfter());
LOGGER.warn("FT546 shadow source read failed: {}; retry after {} ms",
readFailure.getReasonCode(),
readFailure.getRetryAfter().toMillis());
return false;
}
if (failure instanceof Error) {
throw (Error) failure;
}
RuntimeException runtimeFailure = failure instanceof RuntimeException
? (RuntimeException) failure
: new IllegalStateException("shadow cycle failed", failure);
Instant failedAt = clock.instant();
statusReport.markDegraded("SHADOW_CYCLE_FAILED", failedAt);
nextAttemptAt = failedAt.plus(CRASH_BACKOFF);
LOGGER.error("FT546 shadow cycle failed", runtimeFailure);
return false;
}
private boolean stopPermanently(String reasonCode, Exception failure) {
permanentlyStopped.set(true);
nextAttemptAt = Instant.EPOCH;
statusReport.markPermanentlyDegraded(reasonCode, clock.instant());
LOGGER.error("FT546 shadow collection stopped: {}", reasonCode,
failure);
return false;
}
private static Duration requirePositive(Duration value) {
Duration duration = Objects.requireNonNull(value, "cycleDeadline");
if (duration.isZero() || duration.isNegative()
|| duration.toMillis() == 0L) {
throw new IllegalArgumentException(
"cycleDeadline must be at least one millisecond");
}
return duration;
}
private static ThreadFactory shadowCycleThreadFactory() {
return runnable -> {
Thread thread = new Thread(runnable, "ft546-shadow-cycle");
thread.setDaemon(true);
return thread;
};
}
private static ShadowReadCursor advanceFinishCursor(
ShadowReadCursor current, ShadowFactBatch batch) {
Instant latestDate = current.getLastCreateDate();
String latestGuid = current.getLastGuid();
for (ShadowQueueFact fact : batch.getSourceQueues()) {
if (!FINISH_QUEUE_VIEW.equals(fact.getTableName())
|| fact.getCreatedAt() == null) {
continue;
}
int dateOrder = fact.getCreatedAt().compareTo(latestDate);
if (dateOrder > 0 || (dateOrder == 0
&& fact.getGuid().compareTo(latestGuid) > 0)) {
latestDate = fact.getCreatedAt();
latestGuid = fact.getGuid();
}
}
return new ShadowReadCursor(latestDate, latestGuid);
}
private static final class CycleAttempt {
private final ShadowFactBatch batch;
private final ShadowCycleResult result;
private CycleAttempt(ShadowFactBatch batch, ShadowCycleResult result) {
this.batch = batch;
this.result = result;
}
}
}

View File

@@ -14,6 +14,7 @@ public final class ShadowStartupReport {
private volatile Long sourceLagMillis;
private volatile long evidenceBacklog;
private volatile String reasonCode;
private volatile String permanentDegradedReason;
private final Map<String, String> startupChecks;
private ShadowStartupReport(Status status, Instant checkedAt,
@@ -46,6 +47,9 @@ public final class ShadowStartupReport {
public synchronized void markReady(Instant completedAt,
Long sourceLagMillis) {
if (permanentDegradedReason != null) {
return;
}
this.status = Status.READY;
this.checkedAt = completedAt;
this.lastCompletedCycle = completedAt;
@@ -55,12 +59,31 @@ public final class ShadowStartupReport {
public synchronized void markDegraded(String reasonCode,
Instant checkedAt) {
String normalized = requireReasonCode(reasonCode);
if (permanentDegradedReason != null) {
return;
}
this.status = Status.DEGRADED;
this.checkedAt = checkedAt;
this.reasonCode = normalized;
}
public synchronized void markPermanentlyDegraded(String reasonCode,
Instant checkedAt) {
String normalized = requireReasonCode(reasonCode);
if (permanentDegradedReason == null) {
permanentDegradedReason = normalized;
this.checkedAt = checkedAt;
}
this.status = Status.DEGRADED;
this.reasonCode = permanentDegradedReason;
}
private static String requireReasonCode(String reasonCode) {
if (reasonCode == null || reasonCode.trim().isEmpty()) {
throw new IllegalArgumentException(
"reasonCode must not be blank");
}
this.status = Status.DEGRADED;
this.checkedAt = checkedAt;
this.reasonCode = reasonCode.trim();
return reasonCode.trim();
}
}

View File

@@ -6,6 +6,9 @@ spring:
banner-mode: "off"
shadow:
enabled: false
poll:
initial-delay-ms: 5000
fixed-delay-ms: 5000
source:
jdbc-url:
username:

View File

@@ -9,6 +9,7 @@ import com.ruoyi.shadow.startup.ShadowDatabaseGate;
import com.ruoyi.shadow.startup.ShadowStartupReport;
import com.ruoyi.shadow.web.ShadowStatusController;
import com.ruoyi.shadow.kernel.Ft546ShadowCycle;
import com.ruoyi.shadow.kernel.Ft546ShadowScheduler;
import com.ruoyi.shadow.kernel.JdbcShadowTrafficStateRepository;
import com.ruoyi.shadow.kernel.RecordingFt546CommandGateway;
import com.ruoyi.shadow.kernel.ShadowDispatchSnapshotAssembler;
@@ -82,6 +83,7 @@ class Ft546ShadowApplicationTest {
assertNotNull(context.getBean(DispatchCoordinator.class));
assertNotNull(context.getBean(ReadOnlyDispatchCycle.class));
assertNotNull(context.getBean(Ft546ShadowCycle.class));
assertNotNull(context.getBean(Ft546ShadowScheduler.class));
assertNotNull(context.getBean(ShadowDispatchSnapshotAssembler.class));
assertNotNull(context.getBean(RecordingFt546CommandGateway.class));
assertNotNull(context.getBean(

View File

@@ -0,0 +1,315 @@
package com.ruoyi.shadow;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.ft546.protocol.Ft546EncodedCommand;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
import com.ruoyi.shadow.evidence.JdbcShadowEvidenceRepository;
import com.ruoyi.shadow.evidence.ShadowCycleEvidenceCollector;
import com.ruoyi.shadow.evidence.ShadowEvidenceRepository;
import com.ruoyi.shadow.evidence.ShadowEvidenceStorageHealth;
import com.ruoyi.shadow.evidence.ShadowRunEvidence;
import com.ruoyi.shadow.facts.ShadowFactBatch;
import com.ruoyi.shadow.facts.ShadowFactBatchValidator;
import com.ruoyi.shadow.facts.ShadowMapFact;
import com.ruoyi.shadow.facts.ShadowQueueFact;
import com.ruoyi.shadow.facts.ShadowVehicleFact;
import com.ruoyi.shadow.kernel.Ft546ShadowCycle;
import com.ruoyi.shadow.kernel.JdbcShadowTrafficStateRepository;
import com.ruoyi.shadow.kernel.RecordingFt546CommandGateway;
import com.ruoyi.shadow.kernel.ShadowCommandIntentFactory;
import com.ruoyi.shadow.kernel.ShadowCycleResult;
import com.ruoyi.shadow.kernel.ShadowDispatchSnapshotAssembler;
import com.ruoyi.shadow.startup.ShadowStartupReport;
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
import com.ruoyi.wcs.application.dispatch.DispatchCommit;
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy;
import com.ruoyi.wcs.domain.traffic.AStarRoutePlanner;
import com.ruoyi.wcs.domain.traffic.DeadlockGuard;
import com.ruoyi.wcs.domain.traffic.ReservationDecision;
import com.ruoyi.wcs.domain.traffic.ReservationRequest;
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
import com.ruoyi.wcs.domain.traffic.RoutePlanner;
import com.ruoyi.wcs.domain.traffic.WaitForGraph;
import com.ruoyi.wcs.port.out.DispatchPolicy;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.support.TransactionTemplate;
import java.io.InputStream;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class Ft546ShadowEndToEndTest {
private static final String FIXTURES = "/fixtures/";
@Test
void ordinaryCrossFloorAndTwoStageFixturesReachVirtualPlcEvidence()
throws Exception {
ScenarioResult ordinary = run("ordinary-cycle.json");
ScenarioResult crossFloor = run("cross-floor-cycle.json");
ScenarioResult twoStage = run("two-stage-cycle.json");
assertComparable(ordinary, "4004", "4000", "4002", "4021");
assertComparable(crossFloor, "4004", "4000", "4002", "4021",
"4020", "4022");
assertComparable(twoStage, "4404", "4422", "4423", "4402",
"4400");
assertEquals(3, ordinary.persistedCycles + crossFloor.persistedCycles
+ twoStage.persistedCycles);
}
@Test
void avoidanceFixtureProducesHoldWithoutVirtualWrites() throws Exception {
ScenarioResult avoidance = run("hold-avoidance-cycle.json");
assertEquals("COMPARABLE", avoidance.cycle.getStatusCode());
assertEquals(1, avoidance.cycle.getDecisions().getHeld().size(),
"granted=" + avoidance.cycle.getDecisions().getGranted().size()
+ ", yielded=" + avoidance.cycle.getDecisions()
.getYielded().size());
assertEquals("HOLD", avoidance.cycle.getDecisions().getHeld().get(0)
.getDecisionType().name());
assertEquals(Collections.emptyList(),
avoidance.cycle.getEncodedCommands());
assertEquals(1, avoidance.persistedCycles);
}
private static ScenarioResult run(String resource) throws Exception {
ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
Scenario fixture;
try (InputStream input = Ft546ShadowEndToEndTest.class
.getResourceAsStream(FIXTURES + resource)) {
assertNotNull(input, "missing fixture " + resource);
fixture = mapper.readValue(input, Scenario.class);
}
Instant now = Instant.parse(fixture.capturedAt);
DriverManagerDataSource dataSource = new DriverManagerDataSource(
"jdbc:h2:mem:" + UUID.randomUUID()
+ ";MODE=MSSQLServer;DB_CLOSE_DELAY=-1", "sa", "");
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
createSchema(jdbc);
TransactionTemplate transaction = new TransactionTemplate(
new DataSourceTransactionManager(dataSource));
RecordingFt546CommandGateway gateway = gateway();
ShadowCommandIntentFactory intentFactory =
new ShadowCommandIntentFactory();
JdbcShadowTrafficStateRepository traffic =
new JdbcShadowTrafficStateRepository(jdbc, transaction,
mapper, Clock.fixed(now, ZoneOffset.UTC),
intentFactory, gateway);
AtomicInteger ids = new AtomicInteger();
RoutePlanner routePlanner = new AStarRoutePlanner();
DispatchCoordinator coordinator = new DispatchCoordinator(
new DefaultVehicleAssignmentPolicy(routePlanner,
DispatchPolicy.NONE),
new DeadlockGuard(new WaitForGraph(), routePlanner), traffic,
() -> "SHADOW-E2E-" + ids.incrementAndGet());
ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> dispatch =
new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(
coordinator::dispatch, intentFactory::create);
ShadowDispatchSnapshotAssembler assembler =
new ShadowDispatchSnapshotAssembler("FT546", 3L,
topology -> {
com.ruoyi.shadow.kernel.ShadowTrafficState state =
traffic.load(topology);
if (fixture.blockedNode != null
&& state.getSnapshotVersion() == 0L) {
ResourceLedger blockedLedger =
state.getLedger();
ReservationDecision blocked = blockedLedger
.reserve(new ReservationRequest(
"BLOCKER-RES", "BLOCKER-ORDER",
"BLOCKER-STEP", "VEH-BLOCKER",
Collections.singletonList(
"NODE:" + fixture.blockedNode)));
assertTrue(blocked.isGranted());
assertTrue(traffic.commit(new DispatchCommit(
"FT546", topology.version(), 0L,
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList(),
blockedLedger,
state.getWaitForGraph())));
state = traffic.load(topology);
assertEquals(0, state.getLedger()
.availableCapacity("NODE:"
+ fixture.blockedNode));
}
return state;
}, Clock.fixed(now, ZoneOffset.UTC));
UUID runId = UUID.randomUUID();
ShadowRunEvidence run = new ShadowRunEvidence(runId, now,
repeat('a'), repeat('b'), "LOCAL", "SHADOW_E2E", "sa",
"RUNNING");
ShadowEvidenceRepository evidence =
new JdbcShadowEvidenceRepository(jdbc, transaction, mapper,
run);
ShadowStartupReport report = ShadowStartupReport.starting(now,
Collections.emptyMap());
Ft546ShadowCycle cycle = new Ft546ShadowCycle(
new ShadowFactBatchValidator(Duration.ofSeconds(4),
Duration.ofSeconds(30)),
assembler::assemble, dispatch, gateway,
() -> ShadowEvidenceStorageHealth.Check.available(
Long.MAX_VALUE),
evidence, new ShadowCycleEvidenceCollector(runId, 3L, mapper),
report);
ShadowCycleResult result = cycle.run(batch(fixture, now), now);
int persisted = jdbc.queryForObject("SELECT COUNT(*) FROM shadow_cycle",
Integer.class).intValue();
return new ScenarioResult(result, persisted);
}
private static ShadowFactBatch batch(Scenario fixture, Instant now) {
ShadowQueueFact.Builder queue = ShadowQueueFact.builder(
"shadow_ro.v_wcs_srgv_queue", fixture.queueGuid)
.taskNo(fixture.taskNo)
.taskType(fixture.taskType)
.priority(Long.valueOf(fixture.priority))
.cargoPickSite(fixture.startSite)
.cargoPlaceSite(fixture.endSite)
.startSite(fixture.startSite)
.endSite(fixture.endSite)
.createdAt(now.minusSeconds(10))
.default02(fixture.protocolTaskType);
if (fixture.crossFloorTarget != null) {
queue.default07(fixture.crossFloorTarget);
}
if (fixture.stage != null) {
queue.default08(fixture.stage);
}
ShadowVehicleFact vehicle = ShadowVehicleFact.builder(
fixture.vehicleGuid, fixture.vehicleId)
.agvStatus("1")
.currentSite(fixture.vehicleSite)
.battery(80)
.workMode(5)
.alarm(0)
.status(1)
.errorCode(0)
.allowSend(1)
.currentFloor(fixture.vehicleFloor)
.capturedAt(now)
.build();
List<ShadowMapFact> maps = new ArrayList<ShadowMapFact>();
for (MapNode node : fixture.map) {
maps.add(new ShadowMapFact(node.guid, node.siteId, node.x,
node.y, node.z, node.value));
}
return ShadowFactBatch.create(now.minusSeconds(1), now,
Collections.singletonList(queue.build()),
Collections.singletonList(vehicle), maps);
}
private static void assertComparable(ScenarioResult result,
String... addresses) {
assertEquals("COMPARABLE", result.cycle.getStatusCode());
assertEquals(1, result.cycle.getDecisions().getGranted().size());
assertEquals(1, result.cycle.getEncodedCommands().size());
Ft546EncodedCommand encoded = result.cycle.getEncodedCommands().get(0);
List<String> actual = new ArrayList<String>();
encoded.getWrites().forEach(write -> actual.add(write.getAddress()));
assertEquals(Arrays.asList(addresses), actual);
}
private static RecordingFt546CommandGateway gateway() {
Ft546DeviceProfile profile = new Ft546DeviceProfile();
Map<String, String> registers = new LinkedHashMap<String, String>();
registers.put("taskNo", "4000");
registers.put("taskType", "4002");
registers.put("path", "4004");
registers.put("trigger", "4021");
registers.put("crossFloorFlag", "4020");
registers.put("crossFloorTarget", "4022");
registers.put("secondTaskNo", "4400");
registers.put("secondTaskType", "4402");
registers.put("secondPath", "4404");
registers.put("secondStageFlag", "4422");
registers.put("secondComplete", "4423");
profile.setRegisters(registers);
Set<String> capabilities = new LinkedHashSet<String>(Arrays.asList(
"POINT_TO_POINT_COMMAND", "TWO_STAGE_ATOMIC_TASK"));
profile.setCapabilities(capabilities);
return new RecordingFt546CommandGateway(
new Ft546SrgvCommandEncoder(), profile, capabilities);
}
private static void createSchema(JdbcTemplate jdbc) {
jdbc.execute("CREATE TABLE shadow_traffic_state (state_key nvarchar(256) PRIMARY KEY,snapshot_version bigint NOT NULL,state_json nvarchar(max) NOT NULL,updated_at timestamp NOT NULL)");
jdbc.execute("CREATE TABLE shadow_hold_history (parent_task_id nvarchar(128) NOT NULL,vehicle_id nvarchar(64) NOT NULL,resource_key nvarchar(256) NOT NULL,reason_code nvarchar(512) NOT NULL,consecutive_count int NOT NULL,updated_at timestamp NOT NULL,PRIMARY KEY(parent_task_id,vehicle_id,resource_key,reason_code))");
jdbc.execute("CREATE TABLE shadow_run (run_id UUID PRIMARY KEY,started_at TIMESTAMP NOT NULL,artifact_sha256 CHAR(64) NOT NULL,configuration_sha256 CHAR(64) NOT NULL,source_server VARCHAR(256) NOT NULL,source_database VARCHAR(128) NOT NULL,source_login VARCHAR(128) NOT NULL,status VARCHAR(32) NOT NULL)");
jdbc.execute("CREATE TABLE shadow_cycle (cycle_id UUID PRIMARY KEY,run_id UUID NOT NULL,input_batch_hash CHAR(64) NOT NULL,parent_task_id VARCHAR(128) NOT NULL,phase VARCHAR(64) NOT NULL,route_version BIGINT NOT NULL,status VARCHAR(64) NOT NULL,reason_code VARCHAR(512),database_captured_at TIMESTAMP,completed_at TIMESTAMP NOT NULL,evidence_hash CHAR(64) NOT NULL,FOREIGN KEY (run_id) REFERENCES shadow_run(run_id),UNIQUE (run_id,input_batch_hash,parent_task_id,phase,route_version))");
jdbc.execute("CREATE TABLE shadow_input_batch (cycle_id UUID PRIMARY KEY,content_hash CHAR(64) NOT NULL,captured_json CLOB NOT NULL,FOREIGN KEY (cycle_id) REFERENCES shadow_cycle(cycle_id))");
jdbc.execute("CREATE TABLE shadow_legacy_queue (cycle_id UUID PRIMARY KEY,vehicle_id VARCHAR(64),path_info CLOB,result_status VARCHAR(64) NOT NULL,result_json CLOB NOT NULL,content_hash CHAR(64) NOT NULL,FOREIGN KEY (cycle_id) REFERENCES shadow_cycle(cycle_id))");
jdbc.execute("CREATE TABLE shadow_decision (cycle_id UUID PRIMARY KEY,decision VARCHAR(64) NOT NULL,vehicle_id VARCHAR(64),reason_code VARCHAR(512),decision_json CLOB NOT NULL,content_hash CHAR(64) NOT NULL,FOREIGN KEY (cycle_id) REFERENCES shadow_cycle(cycle_id))");
jdbc.execute("CREATE TABLE shadow_encoded_write (cycle_id UUID NOT NULL,write_sequence INT NOT NULL,register_address VARCHAR(16) NOT NULL,data_type VARCHAR(16) NOT NULL,values_json CLOB NOT NULL,trigger_value BIGINT,PRIMARY KEY (cycle_id,write_sequence),FOREIGN KEY (cycle_id) REFERENCES shadow_cycle(cycle_id))");
jdbc.execute("CREATE TABLE shadow_difference (difference_id BIGINT IDENTITY PRIMARY KEY,cycle_id UUID NOT NULL,category VARCHAR(64) NOT NULL,reason_code VARCHAR(512) NOT NULL,expected_json CLOB,actual_json CLOB,FOREIGN KEY (cycle_id) REFERENCES shadow_cycle(cycle_id))");
}
private static String repeat(char value) {
char[] chars = new char[64];
Arrays.fill(chars, value);
return new String(chars);
}
public static final class Scenario {
public String capturedAt;
public String queueGuid;
public String taskNo;
public String taskType;
public long priority;
public String protocolTaskType;
public String crossFloorTarget;
public String stage;
public String vehicleGuid;
public String vehicleId;
public String vehicleSite;
public int vehicleFloor;
public String startSite;
public String endSite;
public String blockedNode;
public List<MapNode> map;
}
public static final class MapNode {
public String guid;
public String siteId;
public int x;
public int y;
public int z;
public int value;
}
private static final class ScenarioResult {
private final ShadowCycleResult cycle;
private final int persistedCycles;
private ScenarioResult(ShadowCycleResult cycle, int persistedCycles) {
this.cycle = cycle;
this.persistedCycles = persistedCycles;
}
}
}

View File

@@ -0,0 +1,440 @@
package com.ruoyi.shadow;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
import com.ruoyi.shadow.evidence.ShadowCycleEvidence;
import com.ruoyi.shadow.evidence.ShadowCycleEvidenceCollector;
import com.ruoyi.shadow.evidence.ShadowEvidenceDigest;
import com.ruoyi.shadow.evidence.ShadowEvidenceException;
import com.ruoyi.shadow.evidence.ShadowEvidenceRepository;
import com.ruoyi.shadow.evidence.ShadowEvidenceStorageHealth;
import com.ruoyi.shadow.facts.ShadowFactBatchValidator;
import com.ruoyi.shadow.facts.ShadowMapFact;
import com.ruoyi.shadow.facts.ShadowQueueFact;
import com.ruoyi.shadow.facts.ShadowReadCursor;
import com.ruoyi.shadow.facts.ShadowVehicleFact;
import com.ruoyi.shadow.facts.SqlServerShadowFactReader;
import com.ruoyi.shadow.kernel.Ft546ShadowCycle;
import com.ruoyi.shadow.kernel.Ft546ShadowScheduler;
import com.ruoyi.shadow.kernel.RecordingFt546CommandGateway;
import com.ruoyi.shadow.kernel.ShadowCommandIntentFactory;
import com.ruoyi.shadow.startup.ShadowStartupReport;
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
import com.ruoyi.wcs.application.dispatch.DispatchCycleResult;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.env.EnumerablePropertySource;
import org.springframework.core.env.PropertySource;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.dao.QueryTimeoutException;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class Ft546ShadowFaultIsolationTest {
private static final Instant NOW = Instant.parse("2026-08-14T06:00:00Z");
@Test
void sourceTimeoutNeverEntersCycleOrRecordsACommand() {
MutableClock clock = new MutableClock(NOW);
AtomicInteger sourceCalls = new AtomicInteger();
SqlServerShadowFactReader reader = reader(timeoutSource(sourceCalls), clock);
RecordingEvidenceRepository evidence = new RecordingEvidenceRepository();
RecordingFt546CommandGateway gateway = gateway();
ShadowStartupReport report = report();
Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(reader,
evidenceCycle(report, evidence, gateway,
() -> ShadowEvidenceStorageHealth.Check.available(
Long.MAX_VALUE)), report, clock);
assertFalse(scheduler.runOnce());
assertEquals(1, sourceCalls.get());
assertEquals(0, evidence.transactionCalls.get());
assertEquals(Collections.emptyList(), gateway.getRecorded());
assertEquals(ShadowStartupReport.Status.DEGRADED, report.getStatus());
assertEquals("SOURCE_QUERY_TIMEOUT", report.getReasonCode());
assertEquals(Duration.ofSeconds(1), scheduler.getRemainingBackoff());
}
@Test
void evidenceFailurePermanentlyStopsLaterSourceReads() {
MutableClock clock = new MutableClock(NOW);
AtomicInteger sourceCalls = new AtomicInteger();
RecordingEvidenceRepository evidence = new RecordingEvidenceRepository();
evidence.saveFailure = new ShadowEvidenceException(
"EVIDENCE_WRITE_FAILED");
RecordingFt546CommandGateway gateway = gateway();
ShadowStartupReport report = report();
Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(
reader(emptySource(sourceCalls), clock),
evidenceCycle(report, evidence, gateway,
() -> ShadowEvidenceStorageHealth.Check.available(
Long.MAX_VALUE)), report, clock);
assertTrue(scheduler.runOnce());
int callsAfterFailure = sourceCalls.get();
clock.advance(Duration.ofMinutes(1));
assertFalse(scheduler.runOnce());
assertEquals(callsAfterFailure, sourceCalls.get());
assertEquals(1, evidence.transactionCalls.get());
assertEquals(Collections.emptyList(), gateway.getRecorded());
assertEquals("EVIDENCE_STORAGE_UNAVAILABLE", report.getReasonCode());
assertTrue(scheduler.isPermanentlyStopped());
}
@Test
void shadowCrashBacksOffWithoutRecordingCommands() {
MutableClock clock = new MutableClock(NOW);
AtomicInteger sourceCalls = new AtomicInteger();
RecordingEvidenceRepository evidence = new RecordingEvidenceRepository();
RecordingFt546CommandGateway gateway = gateway();
ShadowStartupReport report = report();
Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(
reader(emptySource(sourceCalls), clock),
evidenceCycle(report, evidence, gateway,
() -> { throw new IllegalStateException("disk probe crash"); }),
report, clock);
assertFalse(scheduler.runOnce());
int callsAfterCrash = sourceCalls.get();
assertFalse(scheduler.runOnce());
assertEquals(callsAfterCrash, sourceCalls.get());
assertEquals(Collections.emptyList(), gateway.getRecorded());
assertEquals("SHADOW_CYCLE_FAILED", report.getReasonCode());
assertEquals(Duration.ofSeconds(1), scheduler.getRemainingBackoff());
}
@Test
void blockingShadowCycleTimesOutAndPermanentlyStopsCollection()
throws Exception {
MutableClock clock = new MutableClock(NOW);
AtomicInteger sourceCalls = new AtomicInteger();
RecordingEvidenceRepository evidence = new RecordingEvidenceRepository();
RecordingFt546CommandGateway gateway = gateway();
ShadowStartupReport report = report();
CountDownLatch cycleEntered = new CountDownLatch(1);
CountDownLatch releaseCycle = new CountDownLatch(1);
ShadowEvidenceStorageHealth blockingHealth = () -> {
cycleEntered.countDown();
boolean interrupted = false;
while (true) {
try {
releaseCycle.await();
break;
} catch (InterruptedException ignored) {
interrupted = true;
}
}
if (interrupted) {
Thread.currentThread().interrupt();
}
return ShadowEvidenceStorageHealth.Check.available(Long.MAX_VALUE);
};
Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(
reader(emptySource(sourceCalls), clock),
evidenceCycle(report, evidence, gateway, blockingHealth),
report, clock, Duration.ofSeconds(1));
Thread delayedRelease = new Thread(() -> {
try {
Thread.sleep(2500L);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
} finally {
releaseCycle.countDown();
}
}, "shadow-cycle-test-release");
delayedRelease.setDaemon(true);
delayedRelease.start();
long startedAt = System.nanoTime();
try {
assertFalse(scheduler.runOnce());
long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(
System.nanoTime() - startedAt);
int callsAfterTimeout = sourceCalls.get();
assertTrue(cycleEntered.await(0L, TimeUnit.MILLISECONDS));
assertTrue(elapsedMillis < 1800L,
"runOnce must not wait for the blocked cycle");
assertTrue(scheduler.isPermanentlyStopped());
assertEquals(ShadowStartupReport.Status.DEGRADED,
report.getStatus());
assertEquals("SHADOW_CYCLE_TIMEOUT", report.getReasonCode());
assertFalse(scheduler.runOnce());
assertEquals(callsAfterTimeout, sourceCalls.get());
assertEquals(Collections.emptyList(), gateway.getRecorded());
report.markReady(NOW.plusSeconds(1), Long.valueOf(0L));
assertEquals(ShadowStartupReport.Status.DEGRADED,
report.getStatus());
assertEquals("SHADOW_CYCLE_TIMEOUT", report.getReasonCode());
} finally {
releaseCycle.countDown();
scheduler.close();
}
}
@Test
void shippedShadowConfigurationContainsNoPlcOrWmsEndpoint()
throws Exception {
List<PropertySource<?>> sources = new YamlPropertySourceLoader().load(
"shadow", new ClassPathResource("application-shadow.yml"));
List<String> configuredKeys = new ArrayList<String>();
for (PropertySource<?> source : sources) {
if (source instanceof EnumerablePropertySource) {
configuredKeys.addAll(Arrays.asList(
((EnumerablePropertySource<?>) source)
.getPropertyNames()));
}
}
assertTrue(configuredKeys.contains("shadow.source.jdbc-url"));
assertTrue(configuredKeys.contains("shadow.evidence.jdbc-url"));
assertFalse(configuredKeys.stream().anyMatch(
Ft546ShadowFaultIsolationTest::isOutboundEndpointKey));
}
@Test
void activeQueueCannotAdvanceTheFinishHistoryCursor() {
MutableClock clock = new MutableClock(NOW);
Instant finishCreatedAt = NOW.minusSeconds(30);
ShadowQueueFact active = queue(
"shadow_ro.v_wcs_srgv_queue", "ACTIVE-NEW", NOW);
ShadowQueueFact finished = queue(
"shadow_ro.v_wcs_srgv_queue_finish", "FINISH-NEW",
finishCreatedAt);
List<ShadowReadCursor> observed = new ArrayList<ShadowReadCursor>();
SqlServerShadowFactReader.FactSource source = new FactSourceAdapter() {
@Override
public Instant readDatabaseClock(Instant deadline) { return NOW; }
@Override
public List<ShadowQueueFact> readQueues(ShadowReadCursor cursor,
Instant deadline) {
observed.add(cursor);
return Arrays.asList(active, finished);
}
};
RecordingEvidenceRepository evidence = new RecordingEvidenceRepository();
ShadowStartupReport report = report();
Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(
reader(source, clock),
evidenceCycle(report, evidence, gateway(),
() -> ShadowEvidenceStorageHealth.Check.available(
Long.MAX_VALUE)), report, clock);
assertTrue(scheduler.runOnce());
assertTrue(scheduler.runOnce());
assertEquals(2, observed.size());
assertEquals(finishCreatedAt.minusSeconds(5),
observed.get(1).getLastCreateDate());
assertEquals("", observed.get(1).getLastGuid());
}
private static SqlServerShadowFactReader reader(
SqlServerShadowFactReader.FactSource source, Clock clock) {
DriverManagerDataSource dataSource = new DriverManagerDataSource(
"jdbc:h2:mem:" + UUID.randomUUID()
+ ";MODE=MSSQLServer;DB_CLOSE_DELAY=-1", "sa", "");
return new SqlServerShadowFactReader(source,
new TransactionTemplate(
new DataSourceTransactionManager(dataSource)), clock);
}
private static SqlServerShadowFactReader.FactSource timeoutSource(
AtomicInteger calls) {
return new FactSourceAdapter() {
@Override
public Instant readDatabaseClock(Instant deadline) {
calls.incrementAndGet();
throw new QueryTimeoutException("simulated source timeout");
}
};
}
private static SqlServerShadowFactReader.FactSource emptySource(
AtomicInteger calls) {
return new FactSourceAdapter() {
@Override
public Instant readDatabaseClock(Instant deadline) {
calls.incrementAndGet();
return NOW;
}
};
}
private static Ft546ShadowCycle evidenceCycle(
ShadowStartupReport report, RecordingEvidenceRepository evidence,
RecordingFt546CommandGateway gateway,
ShadowEvidenceStorageHealth storageHealth) {
return new Ft546ShadowCycle(
new ShadowFactBatchValidator(Duration.ofSeconds(4),
Duration.ofSeconds(30)),
batch -> { throw new AssertionError("empty facts must not assemble"); },
new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(
snapshot -> DispatchCycleResult.retrySnapshotChanged(),
new ShadowCommandIntentFactory()::create),
gateway, storageHealth, evidence,
new ShadowCycleEvidenceCollector(evidence.runId, 1L,
new ObjectMapper().findAndRegisterModules()), report);
}
private static RecordingFt546CommandGateway gateway() {
Ft546DeviceProfile profile = new Ft546DeviceProfile();
Map<String, String> registers = new LinkedHashMap<String, String>();
registers.put("taskNo", "4000");
registers.put("taskType", "4002");
registers.put("path", "4004");
registers.put("trigger", "4021");
profile.setRegisters(registers);
Set<String> capabilities = new LinkedHashSet<String>(
Collections.singletonList("POINT_TO_POINT_COMMAND"));
profile.setCapabilities(capabilities);
return new RecordingFt546CommandGateway(
new Ft546SrgvCommandEncoder(), profile, capabilities);
}
private static ShadowStartupReport report() {
return ShadowStartupReport.starting(NOW, Collections.emptyMap());
}
private static boolean isOutboundEndpointKey(String key) {
String normalized = key.toLowerCase(java.util.Locale.ROOT)
.replace('-', '.').replace('_', '.');
boolean networkOwner = normalized.contains(".plc.")
|| normalized.startsWith("plc.")
|| normalized.contains(".wms.")
|| normalized.startsWith("wms.");
return networkOwner && (normalized.endsWith(".ip")
|| normalized.endsWith(".host")
|| normalized.endsWith(".port")
|| normalized.endsWith(".url"));
}
private static ShadowQueueFact queue(String table, String guid,
Instant createdAt) {
return ShadowQueueFact.builder(table, guid)
.taskNo("30001")
.taskType("2")
.priority(1L)
.startSite("70501")
.endSite("70502")
.createdAt(createdAt)
.default02("1")
.build();
}
private abstract static class FactSourceAdapter
implements SqlServerShadowFactReader.FactSource {
@Override
public List<ShadowQueueFact> readQueues(ShadowReadCursor cursor,
Instant deadline) {
return Collections.emptyList();
}
@Override
public List<ShadowVehicleFact> readVehicles(Instant deadline) {
return Collections.emptyList();
}
@Override
public List<ShadowMapFact> readMaps(Instant deadline) {
return Collections.emptyList();
}
}
private static final class MutableClock extends Clock {
private Instant now;
private MutableClock(Instant now) { this.now = now; }
private void advance(Duration duration) { now = now.plus(duration); }
@Override
public ZoneId getZone() { return ZoneOffset.UTC; }
@Override
public Clock withZone(ZoneId zone) { return this; }
@Override
public Instant instant() { return now; }
}
private static final class RecordingEvidenceRepository
implements ShadowEvidenceRepository {
private final UUID runId = UUID.randomUUID();
private final AtomicInteger transactionCalls = new AtomicInteger();
private RuntimeException saveFailure;
@Override
public SavedCycle save(ShadowCycleEvidence evidence) {
return saveAll(Collections.singletonList(evidence)).get(0);
}
@Override
public List<SavedCycle> saveAll(List<ShadowCycleEvidence> evidence) {
if (saveFailure != null) {
throw saveFailure;
}
List<SavedCycle> saved = new ArrayList<SavedCycle>();
for (ShadowCycleEvidence value : evidence) {
saved.add(new SavedCycle(value.getCycleId(),
value.getEvidenceHash(), value.getStatus(),
value.getReasonCode()));
}
return saved;
}
@Override
public <T> T inTransaction(Supplier<T> work) {
transactionCalls.incrementAndGet();
return work.get();
}
@Override
public SavedCycle findCycle(UUID runId, String inputBatchHash,
String parentTaskId, String phase,
long routeVersion) {
return null;
}
@Override
public ShadowEvidenceDigest dailyDigest(LocalDate date,
ZoneId zoneId) {
throw new UnsupportedOperationException();
}
@Override
public boolean isHealthy() { return true; }
}
}

View File

@@ -73,7 +73,7 @@ class JdbcShadowTrafficStateRepositoryTest {
}
@Test
void restartReloadPreservesReservationsWaitEdgesHoldsAndFencing() {
void restartReloadPreservesReservationsWaitEdgesAndThreeHoldCount() {
JdbcShadowTrafficStateRepository first = repository();
DispatchCommit initial = commit(0L, protectedLedger(), waitGraph(),
held("CAPACITY_CONFLICT"));
@@ -102,7 +102,17 @@ class JdbcShadowTrafficStateRepositoryTest {
Arrays.asList(twice.getConsecutiveHoldCounts().values()
.toArray(new Integer[0])));
ReservationDecision next = twice.getLedger().reserve(
assertTrue(repository().commit(commit(2L, twice.getLedger(),
twice.getWaitForGraph(), held("CAPACITY_CONFLICT"))));
ShadowTrafficState threeTimes = repository().load(topology);
assertEquals(3L, threeTimes.getSnapshotVersion());
assertEquals(Collections.singleton("VEH-2"),
threeTimes.getWaitForGraph().holdersWaitedOnBy("VEH-1"));
assertEquals(Collections.singletonList(Integer.valueOf(3)),
Arrays.asList(threeTimes.getConsecutiveHoldCounts().values()
.toArray(new Integer[0])));
ReservationDecision next = threeTimes.getLedger().reserve(
new ReservationRequest("RES-2", "ORDER-2", "STEP-2",
"VEH-2", Collections.singletonList("NODE:B")));
assertTrue(next.isGranted());

View File

@@ -0,0 +1,26 @@
spring:
profiles:
active: shadow
server:
address: 127.0.0.1
port: 18091
shadow:
enabled: true
poll:
initial-delay-ms: 5000
fixed-delay-ms: 5000
source:
jdbc-url: ${SHADOW_LOCAL_SOURCE_JDBC_URL:}
username: ${SHADOW_LOCAL_SOURCE_USERNAME:}
password: ${SHADOW_LOCAL_SOURCE_PASSWORD:}
evidence:
jdbc-url: ${SHADOW_LOCAL_EVIDENCE_JDBC_URL:}
username: ${SHADOW_LOCAL_EVIDENCE_USERNAME:}
password: ${SHADOW_LOCAL_EVIDENCE_PASSWORD:}
output-directory: ${SHADOW_LOCAL_OUTPUT_DIRECTORY:./outputs/shadow-local}
minimum-free-bytes: ${SHADOW_LOCAL_MINIMUM_FREE_BYTES:1073741824}
run:
artifact-sha256:
configuration-sha256:
source-server:
source-database:

View File

@@ -0,0 +1,19 @@
{
"capturedAt": "2026-08-14T06:00:00Z",
"queueGuid": "CROSS_FLOOR",
"taskNo": "30002",
"taskType": "2",
"priority": 10,
"protocolTaskType": "8",
"crossFloorTarget": "8",
"vehicleGuid": "VEH-GUID-2",
"vehicleId": "VEH-2",
"vehicleSite": "70501",
"vehicleFloor": 1,
"startSite": "70501",
"endSite": "70502",
"map": [
{"guid": "MAP-1", "siteId": "70501", "x": 0, "y": 0, "z": 1, "value": 1},
{"guid": "MAP-2", "siteId": "70502", "x": 1, "y": 0, "z": 1, "value": 1}
]
}

View File

@@ -0,0 +1,19 @@
{
"capturedAt": "2026-08-14T06:00:00Z",
"queueGuid": "AVOIDANCE",
"taskNo": "30004",
"taskType": "2",
"priority": 10,
"protocolTaskType": "1",
"vehicleGuid": "VEH-GUID-4",
"vehicleId": "VEH-4",
"vehicleSite": "70501",
"vehicleFloor": 1,
"startSite": "70501",
"endSite": "70502",
"blockedNode": "70502",
"map": [
{"guid": "MAP-1", "siteId": "70501", "x": 0, "y": 0, "z": 1, "value": 1},
{"guid": "MAP-2", "siteId": "70502", "x": 1, "y": 0, "z": 1, "value": 1}
]
}

View File

@@ -0,0 +1,18 @@
{
"capturedAt": "2026-08-14T06:00:00Z",
"queueGuid": "ORDINARY",
"taskNo": "30001",
"taskType": "2",
"priority": 10,
"protocolTaskType": "1",
"vehicleGuid": "VEH-GUID-1",
"vehicleId": "VEH-1",
"vehicleSite": "70501",
"vehicleFloor": 1,
"startSite": "70501",
"endSite": "70502",
"map": [
{"guid": "MAP-1", "siteId": "70501", "x": 0, "y": 0, "z": 1, "value": 1},
{"guid": "MAP-2", "siteId": "70502", "x": 1, "y": 0, "z": 1, "value": 1}
]
}

View File

@@ -0,0 +1,19 @@
{
"capturedAt": "2026-08-14T06:00:00Z",
"queueGuid": "STAGE_2",
"taskNo": "30003",
"taskType": "2",
"priority": 10,
"protocolTaskType": "1",
"stage": "2",
"vehicleGuid": "VEH-GUID-3",
"vehicleId": "VEH-3",
"vehicleSite": "70501",
"vehicleFloor": 1,
"startSite": "70501",
"endSite": "70502",
"map": [
{"guid": "MAP-1", "siteId": "70501", "x": 0, "y": 0, "z": 1, "value": 1},
{"guid": "MAP-2", "siteId": "70502", "x": 1, "y": 0, "z": 1, "value": 1}
]
}

View File

@@ -0,0 +1,54 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$StableArtifact,
[int]$StablePid = 0,
[string]$ServiceName,
[string[]]$Configuration = @(),
[Parameter(Mandatory = $true)][string]$OutputJson
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
. (Join-Path $PSScriptRoot 'ShadowScriptCommon.ps1')
$artifact = Resolve-ShadowFile $StableArtifact
$service = $null
if ($ServiceName) {
$service = Get-CimInstance Win32_Service -Filter "Name='$ServiceName'" `
-ErrorAction Stop
if ($StablePid -eq 0) { $StablePid = [int]$service.ProcessId }
}
elseif ($StablePid -gt 0) {
try {
$service = Get-CimInstance Win32_Service -Filter "ProcessId=$StablePid" `
-ErrorAction Stop | Select-Object -First 1
}
catch { $service = $null }
}
if ($StablePid -le 0) { throw 'STABLE_PID_OR_SERVICE_REQUIRED' }
$process = Get-Process -Id $StablePid -ErrorAction Stop
$configurationEvidence = @($Configuration | ForEach-Object {
$path = Resolve-ShadowFile $_
[pscustomobject]@{
path = $path
sha256 = Get-ShadowSha256 $path
}
})
$manifest = [pscustomobject]@{
schemaVersion = 1
capturedAtUtc = [DateTime]::UtcNow.ToString('o')
status = 'PASS'
artifactPath = $artifact
artifactSha256 = Get-ShadowSha256 $artifact
pid = [int]$process.Id
processStartTimeUtc = $process.StartTime.ToUniversalTime().ToString('o')
executablePath = $process.Path
commandLine = Get-ShadowCommandLine $process.Id
serviceName = if ($null -eq $service) { $null } else { $service.Name }
serviceState = if ($null -eq $service) { $null } else { $service.State }
configuration = $configurationEvidence
listeningPorts = @(Get-ShadowListeningPorts $process.Id)
}
Write-ShadowJson -Value $manifest -Path $OutputJson
return $manifest

View File

@@ -0,0 +1,167 @@
[CmdletBinding(DefaultParameterSetName = 'Snapshot')]
param(
[Parameter(Mandatory = $true, ParameterSetName = 'Snapshot')]
[string]$SnapshotDirectory,
[Parameter(Mandatory = $true, ParameterSetName = 'Database')]
[string]$EvidenceServer,
[Parameter(Mandatory = $true, ParameterSetName = 'Database')]
[string]$EvidenceDatabase,
[Parameter(ParameterSetName = 'Database')][string]$EvidenceUsername,
[Parameter(ParameterSetName = 'Database')][switch]$IntegratedSecurity,
[Parameter(Mandatory = $true)][guid]$RunId,
[Parameter(Mandatory = $true)][string]$OutputDirectory
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
. (Join-Path $PSScriptRoot 'ShadowScriptCommon.ps1')
$requiredFiles = @(
'cycles.csv', 'cycles.json', 'protocol-differences.csv',
'algorithm-differences.csv', 'insufficient-facts.csv',
'startup-checks.json'
)
$output = Resolve-ShadowDirectory $OutputDirectory -Create
if (@(Get-ChildItem -LiteralPath $output -Force).Count -gt 0) {
throw 'EVIDENCE_EXPORT_DIRECTORY_MUST_BE_EMPTY'
}
function Convert-DataTableRows {
param([System.Data.DataTable]$Table)
return @($Table.Rows | ForEach-Object {
$value = [ordered]@{}
foreach ($column in $Table.Columns) {
$cell = $_[$column.ColumnName]
$value[$column.ColumnName] = if ($cell -is [DBNull]) { $null } else { $cell }
}
[pscustomobject]$value
})
}
if ($PSCmdlet.ParameterSetName -eq 'Snapshot') {
$snapshot = Resolve-ShadowDirectory $SnapshotDirectory
foreach ($name in $requiredFiles) {
$source = Resolve-ShadowFile (Join-Path $snapshot $name)
Copy-Item -LiteralPath $source -Destination (Join-Path $output $name)
}
}
else {
$builder = New-Object System.Data.SqlClient.SqlConnectionStringBuilder
$builder.DataSource = $EvidenceServer
$builder.InitialCatalog = $EvidenceDatabase
$builder.Encrypt = $true
$builder.TrustServerCertificate = $false
$builder.ConnectTimeout = 5
if ($IntegratedSecurity.IsPresent) {
$builder.IntegratedSecurity = $true
}
else {
if ([string]::IsNullOrWhiteSpace($EvidenceUsername)) {
throw 'EVIDENCE_USERNAME_REQUIRED'
}
$password = [Environment]::GetEnvironmentVariable(
'FT546_SHADOW_EVIDENCE_PASSWORD', 'Process')
if ([string]::IsNullOrWhiteSpace($password)) {
throw 'FT546_SHADOW_EVIDENCE_PASSWORD_REQUIRED'
}
$builder.UserID = $EvidenceUsername
$builder.Password = $password
}
$connection = New-Object System.Data.SqlClient.SqlConnection $builder.ConnectionString
function Invoke-EvidenceQuery {
param([string]$Sql)
$command = $connection.CreateCommand()
$command.CommandTimeout = 10
$command.CommandText = $Sql
$parameter = $command.Parameters.Add('@run_id',
[System.Data.SqlDbType]::UniqueIdentifier)
$parameter.Value = $RunId
$adapter = New-Object System.Data.SqlClient.SqlDataAdapter $command
$table = New-Object System.Data.DataTable
[void]$adapter.Fill($table)
return $table
}
try {
$connection.Open()
$cycles = Invoke-EvidenceQuery @'
SELECT cycle_id,run_id,input_batch_hash,parent_task_id,phase,route_version,
status,reason_code,database_captured_at,completed_at,evidence_hash
FROM dbo.shadow_cycle WHERE run_id=@run_id ORDER BY completed_at,cycle_id
'@
Convert-DataTableRows $cycles | Export-Csv -NoTypeInformation `
-Encoding UTF8 -LiteralPath (Join-Path $output 'cycles.csv')
$protocol = Invoke-EvidenceQuery @'
SELECT d.difference_id,d.cycle_id,d.category,d.reason_code,
d.expected_json,d.actual_json
FROM dbo.shadow_difference d JOIN dbo.shadow_cycle c ON c.cycle_id=d.cycle_id
WHERE c.run_id=@run_id AND d.category LIKE 'PROTOCOL_%'
ORDER BY d.difference_id
'@
Convert-DataTableRows $protocol | Export-Csv -NoTypeInformation `
-Encoding UTF8 -LiteralPath (Join-Path $output 'protocol-differences.csv')
$algorithm = Invoke-EvidenceQuery @'
SELECT d.difference_id,d.cycle_id,d.category,d.reason_code,
d.expected_json,d.actual_json
FROM dbo.shadow_difference d JOIN dbo.shadow_cycle c ON c.cycle_id=d.cycle_id
WHERE c.run_id=@run_id AND d.category LIKE 'ALGORITHM_%'
ORDER BY d.difference_id
'@
Convert-DataTableRows $algorithm | Export-Csv -NoTypeInformation `
-Encoding UTF8 -LiteralPath (Join-Path $output 'algorithm-differences.csv')
$insufficient = Invoke-EvidenceQuery @'
SELECT cycle_id,parent_task_id,phase,status,reason_code,database_captured_at,
completed_at,evidence_hash
FROM dbo.shadow_cycle
WHERE run_id=@run_id AND status='INSUFFICIENT_FACTS'
ORDER BY completed_at,cycle_id
'@
Convert-DataTableRows $insufficient | Export-Csv -NoTypeInformation `
-Encoding UTF8 -LiteralPath (Join-Path $output 'insufficient-facts.csv')
$startup = Invoke-EvidenceQuery @'
SELECT check_id,check_name,status,checked_at FROM dbo.shadow_startup_check
WHERE checked_at >= (SELECT started_at FROM dbo.shadow_run WHERE run_id=@run_id)
ORDER BY check_id
'@
Convert-DataTableRows $startup | ConvertTo-Json -Depth 6 | `
Set-Content -LiteralPath (Join-Path $output 'startup-checks.json') `
-Encoding UTF8
$cycleJson = [ordered]@{
cycles = Convert-DataTableRows $cycles
decisions = Convert-DataTableRows (Invoke-EvidenceQuery `
'SELECT d.* FROM dbo.shadow_decision d JOIN dbo.shadow_cycle c ON c.cycle_id=d.cycle_id WHERE c.run_id=@run_id ORDER BY d.cycle_id')
encodedWrites = Convert-DataTableRows (Invoke-EvidenceQuery `
'SELECT w.* FROM dbo.shadow_encoded_write w JOIN dbo.shadow_cycle c ON c.cycle_id=w.cycle_id WHERE c.run_id=@run_id ORDER BY w.cycle_id,w.write_sequence')
differences = Convert-DataTableRows (Invoke-EvidenceQuery `
'SELECT d.* FROM dbo.shadow_difference d JOIN dbo.shadow_cycle c ON c.cycle_id=d.cycle_id WHERE c.run_id=@run_id ORDER BY d.difference_id')
}
$cycleJson | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath `
(Join-Path $output 'cycles.json') -Encoding UTF8
}
finally {
if ($null -ne $connection) { $connection.Dispose() }
[Environment]::SetEnvironmentVariable(
'FT546_SHADOW_EVIDENCE_PASSWORD', $null, 'Process')
}
}
$files = @($requiredFiles | Sort-Object | ForEach-Object {
$path = Resolve-ShadowFile (Join-Path $output $_)
[pscustomobject]@{
name = $_
length = (Get-Item -LiteralPath $path).Length
sha256 = Get-ShadowSha256 $path
}
})
$manifest = [pscustomobject]@{
schemaVersion = 1
runId = $RunId.ToString()
exportedAtUtc = [DateTime]::UtcNow.ToString('o')
sourceMode = $PSCmdlet.ParameterSetName
files = $files
}
$manifestPath = Join-Path $output 'manifest.json'
Write-ShadowJson -Value $manifest -Path $manifestPath
$manifestHash = Get-ShadowSha256 $manifestPath
"$manifestHash manifest.json" | Set-Content -LiteralPath `
(Join-Path $output 'manifest.sha256') -Encoding ASCII
return $manifest

View File

@@ -0,0 +1,157 @@
function Resolve-ShadowFile {
param([Parameter(Mandatory = $true)][string]$Path)
$item = Get-Item -LiteralPath $Path -ErrorAction Stop
if ($item.PSIsContainer) { throw "FILE_REQUIRED:$Path" }
return $item.FullName
}
function Resolve-ShadowDirectory {
param(
[Parameter(Mandatory = $true)][string]$Path,
[switch]$Create
)
if ($Create -and -not (Test-Path -LiteralPath $Path)) {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
}
$item = Get-Item -LiteralPath $Path -ErrorAction Stop
if (-not $item.PSIsContainer) { throw "DIRECTORY_REQUIRED:$Path" }
return $item.FullName
}
function Write-ShadowJson {
param(
[Parameter(Mandatory = $true)]$Value,
[Parameter(Mandatory = $true)][string]$Path,
[int]$Depth = 12
)
$parent = Split-Path -Parent ([IO.Path]::GetFullPath($Path))
if (-not (Test-Path -LiteralPath $parent)) {
New-Item -ItemType Directory -Path $parent -Force | Out-Null
}
$Value | ConvertTo-Json -Depth $Depth | Set-Content -LiteralPath $Path `
-Encoding UTF8
}
function Read-ShadowJson {
param([Parameter(Mandatory = $true)][string]$Path)
$resolved = Resolve-ShadowFile $Path
return Get-Content -LiteralPath $resolved -Raw | ConvertFrom-Json
}
function Get-ShadowSha256 {
param([Parameter(Mandatory = $true)][string]$Path)
return (Get-FileHash -LiteralPath (Resolve-ShadowFile $Path) `
-Algorithm SHA256).Hash.ToLowerInvariant()
}
function Get-ShadowCommandLine {
param([Parameter(Mandatory = $true)][int]$ProcessId)
try {
$row = Get-CimInstance Win32_Process -Filter "ProcessId=$ProcessId" `
-ErrorAction Stop
return $row.CommandLine
}
catch { return $null }
}
function Get-ShadowListeningPorts {
param([Parameter(Mandatory = $true)][int]$ProcessId)
try {
return @(Get-NetTCPConnection -OwningProcess $ProcessId -State Listen `
-ErrorAction Stop | Sort-Object LocalPort | ForEach-Object {
[pscustomobject]@{
address = $_.LocalAddress
port = [int]$_.LocalPort
state = $_.State.ToString()
}
})
}
catch { return @() }
}
function Test-ShadowPathWithin {
param(
[Parameter(Mandatory = $true)][string]$Candidate,
[Parameter(Mandatory = $true)][string]$Directory
)
$candidatePath = [IO.Path]::GetFullPath($Candidate)
$directoryPath = [IO.Path]::GetFullPath($Directory).TrimEnd('\', '/')
if ($candidatePath.Equals($directoryPath,
[StringComparison]::OrdinalIgnoreCase)) { return $true }
$prefix = $directoryPath + [IO.Path]::DirectorySeparatorChar
return $candidatePath.StartsWith($prefix,
[StringComparison]::OrdinalIgnoreCase)
}
function Get-ShadowFreeBytes {
param([Parameter(Mandatory = $true)][string]$Path)
$root = [IO.Path]::GetPathRoot([IO.Path]::GetFullPath($Path))
return ([IO.DriveInfo]::new($root)).AvailableFreeSpace
}
function Resolve-ShadowScalarPlaceholder {
param([AllowNull()][string]$Value)
if ($null -eq $Value) { return $null }
if ($Value -match '^\$\{([A-Za-z_][A-Za-z0-9_]*):(.*)\}$') {
$environmentValue = [Environment]::GetEnvironmentVariable($matches[1])
return if ($null -eq $environmentValue) { $matches[2] } `
else { $environmentValue }
}
return $Value
}
function Read-ShadowYamlScalars {
param([Parameter(Mandatory = $true)][string]$Path)
$resolved = Resolve-ShadowFile $Path
$parents = @{}
$values = @{}
foreach ($line in Get-Content -LiteralPath $resolved) {
if ($line -match '^\s*(#.*)?$') { continue }
if ($line -notmatch '^(\s*)([A-Za-z0-9_-]+):(?:\s*(.*))?$') {
continue
}
$indent = $matches[1].Length
$key = $matches[2]
$raw = if ($null -eq $matches[3]) { '' } else { $matches[3].Trim() }
foreach ($level in @($parents.Keys | Where-Object { [int]$_ -ge $indent })) {
$parents.Remove($level)
}
if ([string]::IsNullOrWhiteSpace($raw)) {
$parents[$indent] = $key
continue
}
$segments = @($parents.Keys | Sort-Object { [int]$_ } | ForEach-Object {
$parents[$_]
}) + $key
if (($raw.StartsWith('"') -and $raw.EndsWith('"')) -or `
($raw.StartsWith("'") -and $raw.EndsWith("'"))) {
$raw = $raw.Substring(1, $raw.Length - 2)
}
$values[($segments -join '.')] = Resolve-ShadowScalarPlaceholder $raw
}
return $values
}
function Get-ShadowSqlServerJdbcIdentity {
param([Parameter(Mandatory = $true)][string]$JdbcUrl)
if ($JdbcUrl -notmatch '(?i)^jdbc:sqlserver://([^;]+)(?:;(.*))?$') {
throw 'SQLSERVER_JDBC_URL_REQUIRED'
}
$server = $matches[1].Trim()
$properties = @{}
foreach ($part in @($matches[2] -split ';')) {
if ($part -match '^\s*([^=]+)=(.*)$') {
$properties[$matches[1].Trim().ToLowerInvariant()] =
$matches[2].Trim()
}
}
$database = $properties['databasename']
if ([string]::IsNullOrWhiteSpace($database)) {
$database = $properties['database']
}
if ([string]::IsNullOrWhiteSpace($server) -or `
[string]::IsNullOrWhiteSpace($database)) {
throw 'SQLSERVER_JDBC_IDENTITY_INCOMPLETE'
}
return [pscustomobject]@{ server = $server; database = $database }
}

View File

@@ -0,0 +1,112 @@
[CmdletBinding()]
param(
[switch]$ConfirmReadOnlyShadow,
[Parameter(Mandatory = $true)][string]$PreflightJson,
[string]$JavaExecutable = 'java',
[int]$MaximumPreflightAgeMinutes = 30
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
. (Join-Path $PSScriptRoot 'ShadowScriptCommon.ps1')
if (-not $ConfirmReadOnlyShadow.IsPresent) {
throw 'CONFIRM_READ_ONLY_SHADOW_REQUIRED'
}
$preflightPath = Resolve-ShadowFile $PreflightJson
$preflight = Read-ShadowJson $preflightPath
if (-not [bool]$preflight.success) { throw 'SUCCESSFUL_PREFLIGHT_REQUIRED' }
$generatedAt = [DateTime]::Parse($preflight.generatedAtUtc).ToUniversalTime()
if (([DateTime]::UtcNow - $generatedAt).TotalMinutes -gt `
$MaximumPreflightAgeMinutes) {
throw 'PREFLIGHT_EXPIRED'
}
if ($null -eq (Get-Process -Id ([int]$preflight.stable.pid) `
-ErrorAction SilentlyContinue)) {
throw 'STABLE_PROCESS_NOT_HEALTHY'
}
$artifact = Resolve-ShadowFile $preflight.shadowArtifact.path
$config = Resolve-ShadowFile $preflight.config.path
$deployment = Resolve-ShadowDirectory $preflight.deploymentDirectory
if (-not (Test-ShadowPathWithin $artifact $deployment) -or `
-not (Test-ShadowPathWithin $config $deployment)) {
throw 'SHADOW_FILES_MUST_BE_INSIDE_DEPLOYMENT_DIRECTORY'
}
if ((Get-ShadowSha256 $artifact) -ne $preflight.shadowArtifact.sha256) {
throw 'SHADOW_ARTIFACT_CHANGED_AFTER_PREFLIGHT'
}
if ((Get-ShadowSha256 $config) -ne $preflight.config.sha256) {
throw 'SHADOW_CONFIG_CHANGED_AFTER_PREFLIGHT'
}
if ((Get-ShadowSha256 $preflight.permissionReport.path) -ne `
$preflight.permissionReport.sha256) {
throw 'PERMISSION_REPORT_CHANGED_AFTER_PREFLIGHT'
}
if ((Get-ShadowSha256 $preflight.networkDenyReport.path) -ne `
$preflight.networkDenyReport.sha256) {
throw 'NETWORK_DENY_REPORT_CHANGED_AFTER_PREFLIGHT'
}
$pidFile = [IO.Path]::GetFullPath($preflight.pidFile)
if (-not (Test-ShadowPathWithin $pidFile $deployment)) {
throw 'PID_FILE_MUST_BE_INSIDE_DEPLOYMENT_DIRECTORY'
}
if (Test-Path -LiteralPath $pidFile) { throw 'SHADOW_PID_FILE_ALREADY_EXISTS' }
$java = (Get-Command $JavaExecutable -ErrorAction Stop).Source
$logs = Join-Path $deployment 'logs'
New-Item -ItemType Directory -Path $logs -Force | Out-Null
$stdout = Join-Path $logs 'shadow-stdout.log'
$stderr = Join-Path $logs 'shadow-stderr.log'
$configUri = ([uri]$config).AbsoluteUri
$statusUri = [uri]$preflight.statusUri
$runtimeProperties = @(
'--spring.profiles.active=shadow',
'--shadow.enabled=true',
"--spring.config.additional-location=$configUri",
"--server.address=$($statusUri.Host)",
"--server.port=$($statusUri.Port)",
"--shadow.evidence.run.artifact-sha256=$($preflight.shadowArtifact.sha256)",
"--shadow.evidence.run.configuration-sha256=$($preflight.config.sha256)",
"--shadow.evidence.run.source-server=$($preflight.sourceIdentity.server)",
"--shadow.evidence.run.source-database=$($preflight.sourceIdentity.database)",
"--shadow.evidence.minimum-free-bytes=$($preflight.minimumFreeBytes)"
)
$quotedProperties = @($runtimeProperties | ForEach-Object {
'"{0}"' -f $_.Replace('"', '\"')
}) -join ' '
$arguments = '-jar "{0}" {1}' -f $artifact.Replace('"', '\"'), `
$quotedProperties
$process = $null
try {
$process = Start-Process -FilePath $java -ArgumentList $arguments `
-WorkingDirectory $deployment -WindowStyle Hidden -PassThru `
-RedirectStandardOutput $stdout -RedirectStandardError $stderr
Start-Sleep -Milliseconds 300
if ($process.HasExited) {
throw "SHADOW_PROCESS_EXITED_EARLY:$($process.ExitCode)"
}
$record = [pscustomobject]@{
schemaVersion = 1
pid = [int]$process.Id
processStartTimeUtc = $process.StartTime.ToUniversalTime().ToString('o')
javaExecutable = [IO.Path]::GetFullPath($java)
shadowArtifact = $artifact
shadowArtifactSha256 = Get-ShadowSha256 $artifact
config = $config
configSha256 = Get-ShadowSha256 $config
deploymentDirectory = $deployment
preflightJson = $preflightPath
preflightSha256 = Get-ShadowSha256 $preflightPath
startedAtUtc = [DateTime]::UtcNow.ToString('o')
}
Write-ShadowJson -Value $record -Path $pidFile
return $record
}
catch {
if ($null -ne $process -and -not $process.HasExited) {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
}
throw
}

View File

@@ -0,0 +1,58 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$PidFile,
[int]$WaitSeconds = 10
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
. (Join-Path $PSScriptRoot 'ShadowScriptCommon.ps1')
$pidPath = Resolve-ShadowFile $PidFile
$record = Read-ShadowJson $pidPath
$deployment = Resolve-ShadowDirectory $record.deploymentDirectory
if (-not (Test-ShadowPathWithin $pidPath $deployment)) {
throw 'PID_FILE_OUTSIDE_RECORDED_DEPLOYMENT'
}
$artifact = Resolve-ShadowFile $record.shadowArtifact
if ((Get-ShadowSha256 $artifact) -ne $record.shadowArtifactSha256) {
throw 'RECORDED_SHADOW_ARTIFACT_HASH_MISMATCH'
}
$process = Get-Process -Id ([int]$record.pid) -ErrorAction Stop
$actualExecutable = [IO.Path]::GetFullPath($process.Path)
$recordedExecutable = [IO.Path]::GetFullPath($record.javaExecutable)
if (-not $actualExecutable.Equals($recordedExecutable,
[StringComparison]::OrdinalIgnoreCase)) {
throw 'RECORDED_PROCESS_EXECUTABLE_MISMATCH'
}
$recordedStart = [DateTime]::Parse($record.processStartTimeUtc).ToUniversalTime()
$actualStart = $process.StartTime.ToUniversalTime()
if ([Math]::Abs(($actualStart - $recordedStart).TotalSeconds) -gt 1) {
throw 'RECORDED_PROCESS_START_TIME_MISMATCH'
}
$commandLine = Get-ShadowCommandLine $process.Id
if ([string]::IsNullOrWhiteSpace($commandLine) -or `
$commandLine -notlike '*-jar*' -or `
($commandLine -notlike "*$artifact*" -and `
$commandLine -notlike "*$([IO.Path]::GetFileName($artifact))*")) {
throw 'RECORDED_PROCESS_COMMAND_MISMATCH'
}
Stop-Process -Id $process.Id -Force -ErrorAction Stop
$deadline = [DateTime]::UtcNow.AddSeconds($WaitSeconds)
while ([DateTime]::UtcNow -lt $deadline -and `
$null -ne (Get-Process -Id $process.Id -ErrorAction SilentlyContinue)) {
Start-Sleep -Milliseconds 100
}
if ($null -ne (Get-Process -Id $process.Id -ErrorAction SilentlyContinue)) {
throw 'SHADOW_PROCESS_DID_NOT_STOP'
}
$archive = Join-Path $deployment `
("shadow.stopped.{0}.json" -f [DateTime]::UtcNow.ToString('yyyyMMddHHmmssfff'))
Move-Item -LiteralPath $pidPath -Destination $archive
return [pscustomobject]@{
status = 'STOPPED'
pid = [int]$record.pid
stoppedAtUtc = [DateTime]::UtcNow.ToString('o')
archivedPidRecord = $archive
}

View File

@@ -0,0 +1,194 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$StableManifest,
[Parameter(Mandatory = $true)][string]$ShadowArtifact,
[Parameter(Mandatory = $true)][string]$Config,
[Parameter(Mandatory = $true)][string]$DeploymentDirectory,
[Parameter(Mandatory = $true)][string]$PermissionReport,
[Parameter(Mandatory = $true)][string]$NetworkDenyReport,
[uri]$StatusUri = 'http://127.0.0.1:18091/shadow/status',
[long]$MinimumFreeBytes = 1073741824,
[int]$MaximumEvidenceAgeMinutes = 60,
[Parameter(Mandatory = $true)][string]$OutputJson
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
. (Join-Path $PSScriptRoot 'ShadowScriptCommon.ps1')
$checks = New-Object System.Collections.ArrayList
function Add-PreflightCheck {
param([string]$Name, [bool]$Passed, [string]$Detail)
[void]$checks.Add([pscustomobject]@{
name = $Name
status = if ($Passed) { 'PASS' } else { 'FAIL' }
detail = $Detail
})
}
$stablePath = Resolve-ShadowFile $StableManifest
$stable = Read-ShadowJson $stablePath
$artifact = Resolve-ShadowFile $ShadowArtifact
$configPath = Resolve-ShadowFile $Config
$deployment = Resolve-ShadowDirectory $DeploymentDirectory -Create
$permissions = Read-ShadowJson $PermissionReport
$network = Read-ShadowJson $NetworkDenyReport
$configValues = Read-ShadowYamlScalars $configPath
$stableAlive = $null -ne (Get-Process -Id ([int]$stable.pid) `
-ErrorAction SilentlyContinue)
Add-PreflightCheck 'STABLE_PROCESS_HEALTHY' $stableAlive `
"pid=$($stable.pid)"
$stableArtifactExists = Test-Path -LiteralPath $stable.artifactPath -PathType Leaf
$stableHashMatches = $stableArtifactExists -and `
((Get-ShadowSha256 $stable.artifactPath) -eq $stable.artifactSha256)
Add-PreflightCheck 'STABLE_ARTIFACT_HASH_UNCHANGED' $stableHashMatches `
$stable.artifactPath
$stableConfigMatches = $true
foreach ($stableConfig in @($stable.configuration)) {
$stableConfigMatches = $stableConfigMatches -and `
(Test-Path -LiteralPath $stableConfig.path -PathType Leaf) -and `
((Get-ShadowSha256 $stableConfig.path) -eq $stableConfig.sha256)
}
Add-PreflightCheck 'STABLE_CONFIG_HASHES_UNCHANGED' $stableConfigMatches `
"count=$(@($stable.configuration).Count)"
$artifactHash = Get-ShadowSha256 $artifact
$configHash = Get-ShadowSha256 $configPath
$artifactInDeployment = Test-ShadowPathWithin $artifact $deployment
$configInDeployment = Test-ShadowPathWithin $configPath $deployment
Add-PreflightCheck 'SHADOW_ARTIFACT_IN_DEPLOYMENT' $artifactInDeployment $artifact
Add-PreflightCheck 'SHADOW_CONFIG_IN_DEPLOYMENT' $configInDeployment $configPath
$stableDirectory = Split-Path -Parent $stable.artifactPath
$deploymentDistinct = -not ([IO.Path]::GetFullPath($deployment).Equals(
[IO.Path]::GetFullPath($stableDirectory),
[StringComparison]::OrdinalIgnoreCase))
Add-PreflightCheck 'DEPLOYMENT_DISTINCT_FROM_STABLE' $deploymentDistinct `
"stable=$stableDirectory;shadow=$deployment"
$permissionPassed = $permissions.status -eq 'PASS' -and `
[bool]$permissions.source.approvedViewsReadable -and `
([int]$permissions.source.productionWritePermissionCount -eq 0) -and `
[bool]$permissions.evidence.requiredWritesGranted
Add-PreflightCheck 'DATABASE_PERMISSIONS_APPROVED' $permissionPassed `
"source=$($permissions.source.login);evidence=$($permissions.evidence.login)"
$permissionFresh = $false
try {
$permissionAge = ([DateTime]::UtcNow -
[DateTime]::Parse($permissions.generatedAtUtc).ToUniversalTime()).TotalMinutes
$permissionFresh = $permissionAge -ge 0 -and `
$permissionAge -le $MaximumEvidenceAgeMinutes
}
catch { $permissionFresh = $false }
Add-PreflightCheck 'DATABASE_PERMISSION_REPORT_FRESH' $permissionFresh `
"maximumMinutes=$MaximumEvidenceAgeMinutes"
$configSource = $null
$configEvidence = $null
$configIdentityError = $null
try {
$configSource = Get-ShadowSqlServerJdbcIdentity `
$configValues['shadow.source.jdbc-url']
$configEvidence = Get-ShadowSqlServerJdbcIdentity `
$configValues['shadow.evidence.jdbc-url']
}
catch { $configIdentityError = $_.Exception.Message }
$sourceConfigMatches = $null -ne $configSource -and `
$configSource.server.Equals([string]$permissions.source.server,
[StringComparison]::OrdinalIgnoreCase) -and `
$configSource.database.Equals([string]$permissions.source.database,
[StringComparison]::OrdinalIgnoreCase) -and `
([string]$configValues['shadow.source.username']).Equals(
[string]$permissions.source.login,
[StringComparison]::OrdinalIgnoreCase)
Add-PreflightCheck 'CONFIG_SOURCE_IDENTITY_MATCHES_PERMISSION_REPORT' `
$sourceConfigMatches $(if ($configIdentityError) { $configIdentityError } `
else { "config=$($configSource.server)|$($configSource.database)" })
$evidenceConfigMatches = $null -ne $configEvidence -and `
$configEvidence.server.Equals([string]$permissions.evidence.server,
[StringComparison]::OrdinalIgnoreCase) -and `
$configEvidence.database.Equals([string]$permissions.evidence.database,
[StringComparison]::OrdinalIgnoreCase) -and `
([string]$configValues['shadow.evidence.username']).Equals(
[string]$permissions.evidence.login,
[StringComparison]::OrdinalIgnoreCase)
Add-PreflightCheck 'CONFIG_EVIDENCE_IDENTITY_MATCHES_PERMISSION_REPORT' `
$evidenceConfigMatches $(if ($configIdentityError) { $configIdentityError } `
else { "config=$($configEvidence.server)|$($configEvidence.database)" })
$sourceIdentity = ("{0}|{1}" -f $permissions.source.server,
$permissions.source.database).ToLowerInvariant()
$evidenceIdentity = ("{0}|{1}" -f $permissions.evidence.server,
$permissions.evidence.database).ToLowerInvariant()
$databasesDistinct = $sourceIdentity -ne $evidenceIdentity
Add-PreflightCheck 'SOURCE_EVIDENCE_DATABASES_DISTINCT' $databasesDistinct `
"source=$sourceIdentity;evidence=$evidenceIdentity"
$networkPassed = $network.status -eq 'PASS' -and `
[bool]$network.plcEgressDenied -and [bool]$network.wmsEgressDenied
Add-PreflightCheck 'PLC_WMS_EGRESS_DENIED' $networkPassed `
"plc=$($network.plcEgressDenied);wms=$($network.wmsEgressDenied)"
$networkFresh = $false
try {
$networkAge = ([DateTime]::UtcNow -
[DateTime]::Parse($network.generatedAtUtc).ToUniversalTime()).TotalMinutes
$networkFresh = $networkAge -ge 0 -and `
$networkAge -le $MaximumEvidenceAgeMinutes
}
catch { $networkFresh = $false }
Add-PreflightCheck 'NETWORK_DENY_REPORT_FRESH' $networkFresh `
"maximumMinutes=$MaximumEvidenceAgeMinutes"
$loopback = $StatusUri.Host -in @('127.0.0.1', 'localhost', '::1')
Add-PreflightCheck 'STATUS_ENDPOINT_LOOPBACK' $loopback $StatusUri.AbsoluteUri
$stablePorts = @($stable.listeningPorts | ForEach-Object { [int]$_.port })
$portDistinct = $stablePorts -notcontains [int]$StatusUri.Port
Add-PreflightCheck 'SHADOW_PORT_DISTINCT_FROM_STABLE' $portDistinct `
"shadow=$($StatusUri.Port);stable=$($stablePorts -join ',')"
$configText = Get-Content -LiteralPath $configPath -Raw
$outboundKeyPattern = '(?im)^\s*[^#\r\n]*(plc|wms)[^:\r\n]*(ip|host|port|url)\s*:'
$noOutboundEndpoints = -not [regex]::IsMatch($configText, $outboundKeyPattern)
Add-PreflightCheck 'NO_PLC_WMS_ENDPOINT_IN_CONFIG' $noOutboundEndpoints $configPath
$freeBytes = Get-ShadowFreeBytes $deployment
$spaceAvailable = $MinimumFreeBytes -ge 0 -and $freeBytes -ge $MinimumFreeBytes
Add-PreflightCheck 'OUTPUT_FREE_SPACE' $spaceAvailable `
"available=$freeBytes;required=$MinimumFreeBytes"
$pidFile = Join-Path $deployment 'shadow.pid.json'
$noShadowPidFile = -not (Test-Path -LiteralPath $pidFile)
Add-PreflightCheck 'NO_EXISTING_SHADOW_PID_FILE' $noShadowPidFile $pidFile
$failed = @($checks | Where-Object { $_.status -ne 'PASS' })
$result = [pscustomobject]@{
schemaVersion = 1
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
success = $failed.Count -eq 0
checks = @($checks)
stable = $stable
shadowArtifact = [pscustomobject]@{ path = $artifact; sha256 = $artifactHash }
config = [pscustomobject]@{ path = $configPath; sha256 = $configHash }
deploymentDirectory = $deployment
pidFile = $pidFile
statusUri = $StatusUri.AbsoluteUri
sourceIdentity = $permissions.source
evidenceIdentity = $permissions.evidence
configuredSourceIdentity = $configSource
configuredEvidenceIdentity = $configEvidence
minimumFreeBytes = $MinimumFreeBytes
permissionReport = [pscustomobject]@{
path = Resolve-ShadowFile $PermissionReport
sha256 = Get-ShadowSha256 $PermissionReport
}
networkDenyReport = [pscustomobject]@{
path = Resolve-ShadowFile $NetworkDenyReport
sha256 = Get-ShadowSha256 $NetworkDenyReport
}
freeBytes = $freeBytes
}
Write-ShadowJson -Value $result -Path $OutputJson
if ($failed.Count -gt 0) {
throw "SHADOW_PREFLIGHT_FAILED:$($failed.name -join ',')"
}
return $result

View File

@@ -0,0 +1,254 @@
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
function Assert-True {
param([bool]$Condition, [string]$Message)
if (-not $Condition) { throw "ASSERTION_FAILED: $Message" }
}
function Assert-Throws {
param([scriptblock]$Action, [string]$Reason)
try {
& $Action
}
catch {
if ($_.Exception.Message -like "*$Reason*") { return }
throw "EXPECTED_$Reason, ACTUAL=$($_.Exception.Message)"
}
throw "EXPECTED_$Reason, BUT_COMMAND_SUCCEEDED"
}
$toolsDirectory = Split-Path -Parent $PSScriptRoot
$capture = Join-Path $toolsDirectory 'Capture-StableWcsManifest.ps1'
$preflight = Join-Path $toolsDirectory 'Test-Ft546ShadowPreflight.ps1'
$start = Join-Path $toolsDirectory 'Start-Ft546Shadow.ps1'
$stop = Join-Path $toolsDirectory 'Stop-Ft546Shadow.ps1'
$export = Join-Path $toolsDirectory 'Export-Ft546ShadowEvidence.ps1'
foreach ($script in @($capture, $preflight, $start, $stop, $export)) {
Assert-True (Test-Path -LiteralPath $script -PathType Leaf) `
"missing script $script"
}
$testRoot = Join-Path ([IO.Path]::GetTempPath()) `
("ft546-shadow-script-test-" + [guid]::NewGuid().ToString('N'))
$deployment = Join-Path $testRoot 'shadow-deployment'
$stableDirectory = Join-Path $testRoot 'stable-deployment'
$snapshot = Join-Path $testRoot 'evidence-snapshot'
$exportDirectory = Join-Path $testRoot 'evidence-export'
$startedPid = $null
try {
New-Item -ItemType Directory -Path $deployment, $stableDirectory, $snapshot `
-Force | Out-Null
$sourceFile = Join-Path $testRoot 'ShadowSleeper.java'
@'
public final class ShadowSleeper {
public static void main(String[] args) throws Exception {
Thread.sleep(300000L);
}
}
'@ | Set-Content -LiteralPath $sourceFile -Encoding ASCII
& javac -d $testRoot $sourceFile
if ($LASTEXITCODE -ne 0) { throw 'JAVAC_FAILED' }
$shadowArtifact = Join-Path $deployment 'ruoyi-wcs-shadow.jar'
& jar cfe $shadowArtifact ShadowSleeper -C $testRoot 'ShadowSleeper.class'
if ($LASTEXITCODE -ne 0) { throw 'JAR_FAILED' }
$stableArtifact = Join-Path $stableDirectory 'ruoyi-admin.jar'
Copy-Item -LiteralPath $shadowArtifact -Destination $stableArtifact
$stableConfig = Join-Path $stableDirectory 'application.yml'
'spring.profiles.active: stable' | Set-Content -LiteralPath $stableConfig `
-Encoding ASCII
$shadowConfig = Join-Path $deployment 'application-shadow-site.yml'
@'
server:
address: 127.0.0.1
port: 18091
shadow:
enabled: true
source:
jdbc-url: jdbc:sqlserver://source.example.invalid;databaseName=FT546-wcs
username: shadow_reader
evidence:
jdbc-url: jdbc:sqlserver://evidence.example.invalid;databaseName=FT546-shadow
username: shadow_writer
'@ | Set-Content -LiteralPath $shadowConfig -Encoding ASCII
$stableManifest = Join-Path $testRoot 'stable-manifest.json'
& $capture -StableArtifact $stableArtifact -StablePid $PID `
-Configuration $stableConfig -OutputJson $stableManifest | Out-Null
$stable = Get-Content -LiteralPath $stableManifest -Raw | ConvertFrom-Json
Assert-True ($stable.status -eq 'PASS') 'stable manifest must pass'
Assert-True ($stable.pid -eq $PID) 'stable PID must be captured exactly'
$permissionReport = Join-Path $testRoot 'permission-report.json'
[ordered]@{
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
status = 'PASS'
source = [ordered]@{
server = 'source.example.invalid'; database = 'FT546-wcs'
login = 'shadow_reader'; approvedViewsReadable = $true
productionWritePermissionCount = 0
}
evidence = [ordered]@{
server = 'evidence.example.invalid'; database = 'FT546-shadow'
login = 'shadow_writer'; requiredWritesGranted = $true
}
} | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $permissionReport `
-Encoding UTF8
$networkReport = Join-Path $testRoot 'network-deny-report.json'
[ordered]@{
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
status = 'PASS'; plcEgressDenied = $true; wmsEgressDenied = $true
} | ConvertTo-Json | Set-Content -LiteralPath $networkReport -Encoding UTF8
$wrongPermissionReport = Join-Path $testRoot 'wrong-permission-report.json'
$wrongPermissions = Get-Content -LiteralPath $permissionReport -Raw | `
ConvertFrom-Json
$wrongPermissions.source.database = 'WRONG_DATABASE'
$wrongPermissions | ConvertTo-Json -Depth 5 | Set-Content `
-LiteralPath $wrongPermissionReport -Encoding UTF8
Assert-Throws {
& $preflight -StableManifest $stableManifest `
-ShadowArtifact $shadowArtifact -Config $shadowConfig `
-DeploymentDirectory $deployment `
-PermissionReport $wrongPermissionReport `
-NetworkDenyReport $networkReport `
-StatusUri 'http://127.0.0.1:18091/shadow/status' `
-MinimumFreeBytes 0 `
-OutputJson (Join-Path $testRoot 'wrong-preflight.json') | Out-Null
} 'CONFIG_SOURCE_IDENTITY_MATCHES_PERMISSION_REPORT'
$stalePidFile = Join-Path $deployment 'shadow.pid.json'
[ordered]@{ pid = 2147483000 } | ConvertTo-Json | Set-Content `
-LiteralPath $stalePidFile -Encoding UTF8
Assert-Throws {
& $preflight -StableManifest $stableManifest `
-ShadowArtifact $shadowArtifact -Config $shadowConfig `
-DeploymentDirectory $deployment `
-PermissionReport $permissionReport `
-NetworkDenyReport $networkReport `
-StatusUri 'http://127.0.0.1:18091/shadow/status' `
-MinimumFreeBytes 0 `
-OutputJson (Join-Path $testRoot 'stale-pid-preflight.json') | Out-Null
} 'NO_EXISTING_SHADOW_PID_FILE'
Remove-Item -LiteralPath $stalePidFile
$preflightJson = Join-Path $testRoot 'shadow-preflight.json'
& $preflight -StableManifest $stableManifest `
-ShadowArtifact $shadowArtifact -Config $shadowConfig `
-DeploymentDirectory $deployment -PermissionReport $permissionReport `
-NetworkDenyReport $networkReport -StatusUri 'http://127.0.0.1:18091/shadow/status' `
-MinimumFreeBytes 0 -OutputJson $preflightJson | Out-Null
$preflightResult = Get-Content -LiteralPath $preflightJson -Raw | ConvertFrom-Json
Assert-True ([bool]$preflightResult.success) 'preflight must pass'
Assert-True ($preflightResult.stable.pid -eq $PID) `
'preflight must preserve stable PID'
Assert-Throws {
& $start -PreflightJson $preflightJson | Out-Null
} 'CONFIRM_READ_ONLY_SHADOW_REQUIRED'
$configBytes = [IO.File]::ReadAllBytes($shadowConfig)
Add-Content -LiteralPath $shadowConfig -Value '# changed after preflight' `
-Encoding ASCII
Assert-Throws {
& $start -ConfirmReadOnlyShadow -PreflightJson $preflightJson | Out-Null
} 'SHADOW_CONFIG_CHANGED_AFTER_PREFLIGHT'
[IO.File]::WriteAllBytes($shadowConfig, $configBytes)
$startResult = & $start -ConfirmReadOnlyShadow `
-PreflightJson $preflightJson
$startedPid = [int]$startResult.pid
Assert-True ($startedPid -ne $PID) 'shadow PID must differ from stable PID'
Assert-True ($null -ne (Get-Process -Id $startedPid -ErrorAction Stop)) `
'shadow process must be alive after start'
Assert-True ($null -ne (Get-Process -Id $PID -ErrorAction Stop)) `
'stable process must remain alive after shadow start'
$shadowCommandLine = (Get-CimInstance Win32_Process `
-Filter "ProcessId=$startedPid").CommandLine
Assert-True ($shadowCommandLine -like `
'*--shadow.evidence.run.artifact-sha256=*') `
'start must pass the verified artifact hash'
Assert-True ($shadowCommandLine -like `
'*--shadow.evidence.run.configuration-sha256=*') `
'start must pass the verified configuration hash'
Assert-True ($shadowCommandLine -like `
'*--shadow.evidence.run.source-database=FT546-wcs*') `
'start must pass the verified source database'
Assert-True ($shadowCommandLine -like '*--server.address=127.0.0.1*') `
'start must force the preflight loopback address'
Assert-True ($shadowCommandLine -like '*--server.port=18091*') `
'start must force the preflight status port'
$pidFile = Join-Path $deployment 'shadow.pid.json'
& $stop -PidFile $pidFile | Out-Null
Assert-True ($null -eq (Get-Process -Id $startedPid -ErrorAction SilentlyContinue)) `
'recorded shadow PID must stop'
$startedPid = $null
Assert-True ($null -ne (Get-Process -Id $PID -ErrorAction Stop)) `
'stable process must remain alive after shadow stop'
$stableProcess = Get-Process -Id $PID
[ordered]@{
pid = $PID
processStartTimeUtc = $stableProcess.StartTime.ToUniversalTime().ToString('o')
javaExecutable = $stableProcess.Path
shadowArtifact = $shadowArtifact
shadowArtifactSha256 = (Get-FileHash -LiteralPath $shadowArtifact -Algorithm SHA256).Hash
deploymentDirectory = $deployment
} | ConvertTo-Json | Set-Content -LiteralPath $pidFile -Encoding UTF8
Assert-Throws { & $stop -PidFile $pidFile | Out-Null } `
'RECORDED_PROCESS_COMMAND_MISMATCH'
Assert-True ($null -ne (Get-Process -Id $PID -ErrorAction Stop)) `
'mismatched PID must never be stopped'
$requiredEvidence = @(
'cycles.csv', 'cycles.json', 'protocol-differences.csv',
'algorithm-differences.csv', 'insufficient-facts.csv',
'startup-checks.json'
)
foreach ($name in $requiredEvidence) {
"fixture:$name" | Set-Content -LiteralPath (Join-Path $snapshot $name) `
-Encoding UTF8
}
& $export -SnapshotDirectory $snapshot -OutputDirectory $exportDirectory `
-RunId ([guid]::NewGuid()) | Out-Null
Assert-True (Test-Path -LiteralPath (Join-Path $exportDirectory 'manifest.json')) `
'evidence manifest must exist'
Assert-True (Test-Path -LiteralPath (Join-Path $exportDirectory 'manifest.sha256')) `
'evidence manifest hash must exist'
$expectedManifestHash = ((Get-Content -LiteralPath `
(Join-Path $exportDirectory 'manifest.sha256')) -split '\s+')[0]
$actualManifestHash = (Get-FileHash -LiteralPath `
(Join-Path $exportDirectory 'manifest.json') -Algorithm SHA256).Hash
Assert-True ($expectedManifestHash -eq $actualManifestHash.ToLowerInvariant()) `
'evidence manifest hash must verify'
foreach ($name in $requiredEvidence) {
Assert-True (Test-Path -LiteralPath (Join-Path $exportDirectory $name)) `
"evidence export missing $name"
}
[pscustomobject]@{
status = 'PASS'
stablePid = $PID
preflight = $preflightJson
exportManifest = (Join-Path $exportDirectory 'manifest.json')
}
}
finally {
if ($null -ne $startedPid) {
Stop-Process -Id $startedPid -Force -ErrorAction SilentlyContinue
}
$resolvedTemp = [IO.Path]::GetFullPath($testRoot)
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
if ($resolvedTemp.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase) `
-and (Test-Path -LiteralPath $resolvedTemp)) {
Remove-Item -LiteralPath $resolvedTemp -Recurse -Force
}
}