test(qa): verify native Kingbase restore migration and fusion workflows

This commit is contained in:
wangzhibiao committed 2026-10-14 18:31:38 +08:00
1 parent ba5dc7a6af
commit 6e73d34561
23 files changed
+1103 -31

No files matched your search

+66 -15
View File
@@ -5,9 +5,15 @@
## 当前验收边界
Round 5V-3 已成功接管SSH管理入口,完成匹配V9原生一致性备份及归档完整读取。
005授权有效,但独立恢复环境尚未确认或获准创建,当前状态为
`MIGRATION_AUTHORIZED_BLOCKED_ISOLATED_RESTORE`,005未执行。
Round 5V-4 已在获准的新建离线 Kingbase V9 容器中实际恢复原生备份,完成
005 的提交、重复执行、异常/断连/锁超时回滚及原备份恢复演练,然后才对
`railwaysystem_fusion_qa_20261013` 执行 16 列 / 4 索引增量迁移。
11 项真实 Kingbase Service/Mapper/事务测试(含独立测试 JVM 强制中断)已通过。
当前 QA 后端、15 个 Chrome 路由与合成学员入住/调宿/退宿的 Mock 双回执闭环已实际验收。
详见 [本轮软件验收记录](docs/iot/ROUND5V4_SOFTWARE_QA.md)。
但收尾18:27实测服务器22/4321均超时,后端出现08001/EOF;当前就绪状态为
`BLOCKED_NETWORK`,不是软件功能失败或迁移未执行。已停止本轮本机QA三进程。
管理员恢复合法连接后,重新执行只读预检和隔离启动即可;不要重做005、覆盖恢复或修改系统路由。
不能将 H2/替身测试、构建成功或登录页显示称为
Kingbase 事务/浏览器业务闭环验收。真实硬件验收始终未进行。
迁移前置、失败回滚和核验清单见 [迁移计划](docs/iot/ROUND5V_KINGBASE_MIGRATION_PLAN.md)。
@@ -30,6 +36,7 @@ bash scripts/bootstrap/install-kingbase-jdbc.sh
bash scripts/bootstrap/check-kingbase-jdbc.sh
mvn -B -f RuoYi-Vue/pom.xml -pl ruoyi-admin -am '-Dtest=Round5s*,Round5vAccommodationAtomicityTest,RealIotSecurityGateTest,FaceExtractClientMockTest,CommandRespHandlerMockTest,DmsHttpClientTest,DmsTokenManagerTest,TrainIotLockAuthBridgeMockTest' -Dsurefire.failIfNoSpecifiedTests=false package
python3 -m unittest discover -s scripts/qa -p test_qa_tools.py -v
mvn -B -f RuoYi-Vue/pom.xml -pl ruoyi-admin -am -Dtest=Round5v4MissingResourceTest -Dsurefire.failIfNoSpecifiedTests=false test
```
驱动私有坐标:`cn.com.kingbase:kingbase8:9.0.0`;SHA256:
@@ -72,11 +79,12 @@ V009R003C018原生工具;现有kingbase管理角色可经容器本地连接认
fusion_qa_app提权。已完成原生备份,服务器及本机副本均在非Git目录,目录0700、文件0600。
最新位置、SHA及独立恢复解阻方案见 [本轮记录](docs/iot/ROUND5V3_SERVER_BACKUP.md)。
`sys_restore --file=/dev/null`成功仅证明归档可完整读取,不能替代实际数据库恢复。
本机Docker守护进程不可用;服务器只确认一个共享V9实例,旧V8目录不是获准的
兼容恢复环境。需提供已有独立恢复目标,或明确另行批准隔离容器/实例及恢复库。
不要自行创建数据库、启动新容器或复用共享库进行覆盖恢复。
Round 5V-4 已另获明确授权,复用服务器现有 V9 镜像创建专用隔离恢复容器,
原生恢复及迁移/回滚均已实际验证;该授权不扩展到其他实例或数据库。
恢复证据及新建资源身份见本轮记录;不要把一次性演练脚本当作通用初始化脚本重跑,
不要复用共享库进行覆盖恢复。历史 Round 5V-3 文档保留为当时的真实阻塞记录。
## 3. 启停隔离 QA(迁移批准并完成后)
## 3. 启停隔离 QA(目标库 005 已完成)
```bash
python3 scripts/qa/qa.py start --config /protected/round5v.env
@@ -91,6 +99,9 @@ python3 scripts/qa/qa.py frontend
whitelist空 / protocol-verified=false / retry-task-enabled=false;人脸 HTTP关闭;
OpenAPI、QA危险接口、Druid控制台和Quartz自动启动关闭;旧DMS关闭。
继承环境中的 Spring/IoT/DMS/JVM 注入覆盖被过滤,数据库 URL/账号由命令行固定。
启动器将构建包复制为 `.qa-runtime/ruoyi-admin-<SHA前缀>.jar` 并校验散列,防止 Maven
覆盖正在运行的嵌套 JAR。Druid URL 去除 JDBC 秒单位超时参数,池统一使用连接30秒/
网络60秒;实际 Kingbase/Druid 探针验证了 60000ms,不能把 URL 的 15 当成池毫秒值。
JWT和独立Redis密钥由安全随机数首次生成至 `.qa-runtime/runtime-secrets.env`,0600,
以进程环境注入,不写源码/命令行;数据库及身份密钥仅取受保护配置文件。
不得为演示去打开真实门禁或危险接口。
@@ -101,10 +112,48 @@ curl --noproxy '*' --fail http://127.0.0.1:18096/captchaImage
python3 scripts/qa/qa.py stop
```
stop只处理本脚本账本中且进程命令身份仍匹配的PID;不会清端口、杀其他Chrome、
stop只处理本脚本账本中且进程命令身份仍匹配的PID,等待其退出再移除账本;不会清端口、杀其他Chrome、
停止实验工程或修改系统网络。JWT、访问日志、快照、浏览器证据均不进入Git。
## 4. 迁移后验收清单(未完成项不得标 PASS)
## 4. 可复现的真实 Kingbase / Chrome 软件验收
仅在已迁移、已批准合成写入的同名 QA 库执行:
```bash
python3 scripts/qa/run_kingbase_tests.py --approve-synthetic-qa-writes --config /protected/round5v.env
# 真实 Kingbase 11 项;不使用 H2,不启动任何真实 MQTT / 人脸客户端。
# 合成数据有 R5V4 标识并保留审计,不删除既有数据,不重置序列。
```
Chrome 验收使用 Playwright CLI 技能(本轮实际 Chrome 155.0.8059.40),打开独立
`round5v4` 会话后运行:
```bash
python3 scripts/qa/browser_login.py --config /protected/round5v.env
# 若 QA 自身启用验证码:从实际截图识别后,加 --use-current-page --captcha ACTUAL_ANSWER;
# 不关闭验证码。本轮现有配置的 captchaImage 返回 captchaEnabled=false,未改其开关。
playwright-cli -s=round5v4 run-code --filename=scripts/qa/browser_regression.js
python3 scripts/qa/browser_fixture.py seed --approve-synthetic-qa-writes --config /protected/round5v.env
```
登录配置额外需要受保护文件中的 QA_ADMIN_USERNAME / QA_ADMIN_PASSWORD。登录驱动仅
在独立 about:blank 页内存中读取配置,关闭该页后填写登录表单,不把配置上传至应用。
CLI 不具备本机工具时使用技能自带 wrapper;不要为此修改工程依赖或系统浏览器设置。
`seed` 仅首次执行,生成 `.qa-runtime/round5v4/browser-fixture.properties`;已存在则拒绝覆盖。
按该文件学号在动态菜单 `/equip/accommodation` 搜索,依次入住、调房至同标识 B 宿舍、退宿。
每次操作后,先观察 AWAITING_ACK,然后执行受控回执驱动,再到授权和指令页面核对:
```bash
python3 scripts/qa/browser_fixture.py state --approve-synthetic-qa-writes --config /protected/round5v.env
python3 scripts/qa/browser_fixture.py ack --approve-synthetic-qa-writes --config /protected/round5v.env
python3 scripts/qa/browser_fixture.py verify --approve-synthetic-qa-writes --config /protected/round5v.env
```
驱动使用真实 Mapper/Service,只接受该文件合成人员与两把合成门锁、MOCK 来源及
`round5v-local-mock` 环境的当前 AWAITING_ACK 指令,不开放任何 HTTP 回执端点。
授权页按人员编号过滤,指令页按 fixture.marker(分组编号)过滤,应有四条 CONFIRMED 指令。
住宿 V2 模型只持久化分配与最后变更时间,页面已按真实字段标注,不捏造独立入住/退宿时间戳。
1. 管理员真实登录、getInfo/getRouters、原品牌菜单和首页图表;培训核心查询。
2. IoT九页:group/gateway/device/face/auth/command/accesslog/mqttlog/config,
@@ -116,13 +165,15 @@ stop只处理本脚本账本中且进程命令身份仍匹配的PID;不会清
ACK由隔离的Service测试驱动,不通过开放生产式控制接口来换取通过。
5. 受控Mock ACK成功只能证明软件闭环,不能证明真实门锁已授权或撤权。
现有 `Round5sExecutionIntegrationTest` 的文件型H2证据不是第4项的替代品;
`Round5vAccommodationAtomicityTest`证明异常传播/事务声明,不证明Kingbase回滚。
现有 H2 与替身测试仍不等于真实数据库证据;本轮新增
`Round5v4KingbaseIntegrationTest` 才是上述 Kingbase 事务和应用进程中断验证的执行入口。
## 5. CI与当前阻塞
## 5. CI与硬件边界
`.gitlab-ci.yml` 已改为显式web+RUN_FUSION_VALIDATION=true才允许的验证流水线;
Java21、私有JDBC预检及npm ci,**无部署job**。本轮未Push,也未触发/验证远程Runner。
005授权、原生备份及既有管理连接均已确认;当前仍缺实际独立恢复证明、真实Kingbase生命周期验证和
依托新QA后端的完整Chrome验收。迁移安全前提未满足时,单独前端会明确返回502,
不得用旧实验后端或伪造API响应冒充新版本验收。
本轮数据库、事务、启动和 Chrome 软件验收已经完成;仍未做真实门锁、人脸或网关验收。
硬件测试另需老师批准、确认协议中未验证的帧校验字段/回执及重传策略、单台白名单和人工核对流程。
商业驱动和恢复容器许可仅按现有合法 QA/试用环境验证,不代表生产部署许可。
应用账号既有的三张系统表过宽权限未扩大也未自动收回,应由管理员另行最小化。
任何安全前提不满足时,不得用旧实验后端或伪造 API 响应冒充新版本验收。
@@ -29,6 +29,14 @@ public class GlobalExceptionHandler
{
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
/** Disabled/absent endpoints are 404, not a logged system failure or an exception-detail leak. */
@ExceptionHandler(org.springframework.web.servlet.resource.NoResourceFoundException.class)
@org.springframework.web.bind.annotation.ResponseStatus(org.springframework.http.HttpStatus.NOT_FOUND)
public AjaxResult handleMissingResource(org.springframework.web.servlet.resource.NoResourceFoundException e)
{
return AjaxResult.error(HttpStatus.NOT_FOUND, "接口不存在或未启用");
}
/**
* 权限校验异常
*/
@@ -0,0 +1,20 @@
package com.ruoyi.framework.security;
import com.ruoyi.framework.web.exception.GlobalExceptionHandler;
import org.junit.Test;
import static org.junit.Assert.*;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.http.HttpMethod;
public class Round5v4MissingResourceTest {
@Test public void absentSensitiveEndpointsReturnRedacted404RatherThan500() throws Exception {
GlobalExceptionHandler h=new GlobalExceptionHandler();
Object body=h.handleMissingResource(new NoResourceFoundException(HttpMethod.POST,"iot/mqtt/publish","/iot/mqtt/publish"));
assertEquals(404,((java.util.Map<?,?>)body).get("code"));
assertFalse(body.toString().contains("static resource"));
assertFalse(body.toString().contains("iot/mqtt"));
ResponseStatus status=GlobalExceptionHandler.class.getMethod("handleMissingResource",NoResourceFoundException.class).getAnnotation(ResponseStatus.class);
assertEquals(404,status.value().value());
}
}
+2
View File
@@ -16,6 +16,8 @@
</description>
<dependencies>
<!-- Authorized private vendor artifact; only opt-in real Kingbase QA tests need it here. -->
<dependency><groupId>cn.com.kingbase</groupId><artifactId>kingbase8</artifactId><scope>test</scope></dependency>
<!-- 通用工具-->
<dependency>
@@ -15,6 +15,7 @@ import com.ruoyi.iot.lock.mapper.LockDeviceMapper;
import com.ruoyi.iot.lock.service.ILockAuthService;
import com.ruoyi.iot.lock.service.ILockCommandService;
import com.ruoyi.iot.lock.service.ILockFaceService;
import com.ruoyi.iot.lock.service.LockExecutionTransactions;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.TrainDormitoryInfo;
@@ -92,6 +93,9 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe
@Autowired
private com.ruoyi.iot.lock.security.RealIotSecurityGate securityGate;
@Autowired
private LockExecutionTransactions executionTransactions;
@org.springframework.beans.factory.annotation.Value("${train.lock.password-fallback-enabled:false}")
private boolean passwordFallbackEnabled = false;
@@ -553,7 +557,9 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe
}
try
{
lockAuthService.push(authId);
// AFTER_COMMIT still exposes the completed business transaction's resources.
// Enqueue in REQUIRES_NEW so its own commit callback actually dispatches.
executionTransactions.committed(() -> { lockAuthService.push(authId); return null; });
}
catch (Exception ex)
{
@@ -569,7 +575,7 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe
}
try
{
lockAuthService.clear(authId);
executionTransactions.committed(() -> { lockAuthService.clear(authId); return null; });
}
catch (Exception ex)
{
@@ -140,7 +140,7 @@
SELECT
COALESCE(cr.room_name, ts.site_name, '未知场地') AS venue_name,
count(1) AS usage_count,
ROUND(SUM((EXTRACT(EPOCH FROM l.end_time) - EXTRACT(EPOCH FROM l.start_time)) / 3600.0)::numeric, 1) AS total_hours
ROUND(CAST(SUM((EXTRACT(EPOCH FROM l.end_time) - EXTRACT(EPOCH FROM l.start_time)) / 3600.0) AS numeric), 1) AS total_hours
FROM train_lesson l
LEFT JOIN train_classroom_info cr ON cr.class_room_id = l.class_room_id
LEFT JOIN train_training_site ts ON ts.site_id = l.site_id
@@ -0,0 +1,49 @@
package com.ruoyi.train.integration.lock;
import java.io.*;
import java.nio.file.*;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.*;
import com.ruoyi.iot.lock.domain.*;
import com.ruoyi.iot.lock.security.ExecutionStates;
/** Offline CLI fixture: no web endpoint, no real MQTT client, only explicitly marked synthetic QA records. */
public final class Round5v4BrowserFixture {
public static void main(String[] args) throws Exception {
Round5v4KingbaseIntegrationTest.Harness h=new Round5v4KingbaseIntegrationTest.Harness("round5v-local-mock");
Path path=Paths.get(System.getenv("ROUND5V4_LOG_DIR"),"browser-fixture.properties");
Properties p=new Properties();
if("seed".equals(args[0])) {
if(Files.exists(path))throw new SecurityException("Fixture already exists; preserve its audit trail");
h.seed();p.setProperty("marker",h.marker);p.setProperty("studentId",h.marker+"S");p.setProperty("person",h.person);
p.setProperty("dormA",h.dormA);p.setProperty("dormB",h.dormB);p.setProperty("acc",String.valueOf(h.acc));
p.setProperty("deviceA",String.valueOf(h.deviceA));p.setProperty("deviceB",String.valueOf(h.deviceB));
Files.createFile(path,PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
try(Writer w=Files.newBufferedWriter(path)){p.store(w,"SYNTHETIC ONLY; no credentials");}
System.out.println("BROWSER_FIXTURE_CREATED "+h.marker);return;
}
try(Reader r=Files.newBufferedReader(path)){p.load(r);}
if(!p.getProperty("marker","").matches("R5V4[0-9a-f]{16}"))throw new SecurityException("Invalid fixture identity");
String person=p.getProperty("person");Set<Long> devices=new HashSet<>(Arrays.asList(Long.valueOf(p.getProperty("deviceA")),Long.valueOf(p.getProperty("deviceB"))));
java.util.List<LockAuth> all=h.authMapper.selectAuthsByPersonNo(person);
for(LockAuth auth:all) {
if(!devices.contains(auth.getDeviceId()) || !h.gate.sameOrigin(auth) || !"MOCK".equals(auth.getExecutionMode()))throw new SecurityException("Non-fixture authorization");
LockCommand c=h.commandMapper.selectCommandByIntentKey(auth.getIntentKey());
if("ack".equals(args[0]) && c!=null && ExecutionStates.AWAITING_ACK.equals(c.getExecutionState())) {
if(!"MOCK".equals(c.getExecutionMode()) || !h.gate.sameOrigin(c))throw new SecurityException("Non-Mock command");
h.confirm(c);
} else if(!Arrays.asList("ack","state","verify").contains(args[0]))throw new IllegalArgumentException("seed/ack/state/verify only");
auth=h.authMapper.selectAuthById(auth.getAuthId());
System.out.println("FIXTURE_AUTH id="+auth.getAuthId()+" action="+auth.getIntentAction()+" version="+auth.getIntentVersion()+" state="+auth.getExecutionState()+" sync="+auth.getSyncStatus());
}
if("verify".equals(args[0])) {
org.junit.Assert.assertEquals(2,all.size());
for(LockAuth auth:all){org.junit.Assert.assertEquals(ExecutionStates.REVOKE,auth.getIntentAction());org.junit.Assert.assertEquals(ExecutionStates.CONFIRMED,auth.getExecutionState());org.junit.Assert.assertEquals("4",auth.getSyncStatus());}
java.util.List<Long> commands=h.jdbc.queryForList("select c.command_id from iotlocks.lock_command c join iotlocks.lock_auth a on a.auth_id=c.biz_id where a.person_no=? and c.environment_id=? order by c.command_id",Long.class,person,h.env);
org.junit.Assert.assertEquals(4,commands.size());
for(Long id:commands){LockCommand c=h.commandMapper.selectCommandById(id);org.junit.Assert.assertEquals("MOCK",c.getExecutionMode());org.junit.Assert.assertEquals(ExecutionStates.CONFIRMED,c.getExecutionState());org.junit.Assert.assertEquals("1",c.getGatewayAck());org.junit.Assert.assertEquals("1",c.getDeviceAck());System.out.println("FIXTURE_COMMAND id="+id+" type="+c.getCommandType()+" mode=MOCK state=CONFIRMED dual_ack=true");}
org.junit.Assert.assertEquals(Integer.valueOf(2),h.jdbc.queryForObject("select count(*) from public.train_accommodation a join public.train_enrollment e on e.enrollment_id=a.enrollment_id where e.student_id=? and a.accommodation_status=2",Integer.class,p.getProperty("studentId")));
System.out.println("FIXTURE_BROWSER_DATABASE_CLOSED_LOOP_PASS");
}
}
}
@@ -0,0 +1,227 @@
package com.ruoyi.train.integration.lock;
import static org.junit.Assert.*;
import java.io.*;
import java.lang.reflect.*;
import java.nio.file.*;
import java.nio.file.attribute.PosixFilePermissions;
import java.sql.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import javax.sql.DataSource;
import org.junit.*;
import org.mybatis.spring.*;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import com.ruoyi.iot.config.IotMqttProperties;
import com.ruoyi.iot.lock.LockConstants;
import com.ruoyi.iot.lock.domain.*;
import com.ruoyi.iot.lock.mapper.*;
import com.ruoyi.iot.lock.protocol.*;
import com.ruoyi.iot.lock.security.*;
import com.ruoyi.iot.lock.service.*;
import com.ruoyi.iot.lock.service.impl.*;
import com.ruoyi.iot.service.IMqttService;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.integration.lock.service.ITrainLockAuthBridgeService;
import com.ruoyi.train.integration.lock.service.impl.TrainIotLockAuthBridgeServiceImpl;
import com.ruoyi.train.mapper.*;
import com.ruoyi.train.service.ITrainAccommodationService;
import com.ruoyi.train.service.impl.TrainAccommodationServiceImpl;
/** Opt-in REAL Kingbase, real production XML/Services/transaction manager. All egress is a fake transport. */
public class Round5v4KingbaseIntegrationTest {
private Harness h;
@Before public void setup() throws Exception {
Assume.assumeTrue("Explicit QA write opt-in required", "1".equals(System.getenv("ROUND5V4_KINGBASE_TESTS")));
h=new Harness("round5v4-qa-"+UUID.randomUUID()); h.seed();
}
@After public void preserveSyntheticAuditRows() {
if(h==null)return;
h.props.setMock(true);
// Preserve all synthetic records; close only never-attempted test work, never historical business rows.
for(Long id:h.jdbc.queryForList("select command_id from iotlocks.lock_command where environment_id=? and execution_state='READY' and attempt_started_at is null",Long.class,h.env)) {
try {h.commands.cancel(id);}catch(RuntimeException ignored) { }
}
h.jdbc.update("update iotlocks.lock_auth set execution_state='MANUAL_REVIEW',remark='Round5V4 synthetic test complete; preserved for audit' where environment_id=? and execution_state='READY'",h.env);
}
@Test public void checkinTransferCheckoutRealTransactionsAndMockDualAck() throws Exception {
h.accommodations.checkIn(h.acc,"round5v4");LockAuth first=h.auth(h.deviceA);h.confirm(h.latest(first));
assertEquals(ExecutionStates.CONFIRMED,h.auth(h.deviceA).getExecutionState());
TrainAccommodation next=h.accommodations.transfer(h.acc,h.dormB,"1","round5v4");
LockAuth old=h.auth(h.deviceA),fresh=h.auth(h.deviceB);
assertEquals(ExecutionStates.REVOKE,old.getIntentAction());assertEquals(ExecutionStates.GRANT,fresh.getIntentAction());
h.confirm(h.latest(old));h.confirm(h.latest(fresh));
h.accommodations.checkOut(next.getAccommodationId(),"round5v4");h.confirm(h.latest(h.auth(h.deviceB)));
assertEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceA).getSyncStatus());
assertEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceB).getSyncStatus());
assertEquals(Integer.valueOf(2),h.trainMapper.selectTrainAccommodationByAccommodationId(next.getAccommodationId()).getAccommodationStatus());
}
@Test public void revokeIntentBeforeCommandSurvivesActualJvmHalt() throws Exception {
h.checkedIn();assertEquals(73,h.child("intent",h.acc));
LockAuth pending=h.auth(h.deviceA);assertEquals("1",pending.getStatus());assertEquals(ExecutionStates.REVOKE,pending.getIntentAction());
assertNull(h.commandMapper.selectCommandByIntentKey(pending.getIntentKey()));
h.restart();h.auths.retryPending();LockCommand revoke=h.latest(pending);assertNotNull(revoke);h.confirm(revoke);
assertEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceA).getSyncStatus());
assertEquals(Integer.valueOf(2),h.trainMapper.selectTrainAccommodationByAccommodationId(h.acc).getAccommodationStatus());
}
@Test public void readyRevokeBeforeSendSurvivesActualJvmHalt() throws Exception {
h.checkedIn();assertEquals(75,h.child("ready",h.auth(h.deviceA).getAuthId()));
LockCommand revoke=h.latest(h.auth(h.deviceA));assertEquals(ExecutionStates.READY,revoke.getExecutionState());assertNull(revoke.getAttemptStartedAt());
h.restart();h.commands.retry(revoke.getCommandId());assertEquals(1,h.transport.attempts.get());h.confirm(revoke);
assertEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceA).getSyncStatus());
}
@Test public void committedClaimUnknownAfterJvmHaltNeverBlindlyReplays() throws Exception {
h.checkedIn();assertEquals(74,h.child("claim",h.auth(h.deviceA).getAuthId()));
LockCommand revoke=h.latest(h.auth(h.deviceA));assertEquals(ExecutionStates.DISPATCHING,revoke.getExecutionState());assertNotNull(revoke.getAttemptStartedAt());
h.jdbc.update("update iotlocks.lock_command set attempt_started_at=? where command_id=?",new Timestamp(System.currentTimeMillis()-120000),revoke.getCommandId());
h.restart();h.commands.markTimeout(30);h.commands.retryPending();h.auths.retryPending();
assertEquals(ExecutionStates.MANUAL_REVIEW,h.commandMapper.selectCommandById(revoke.getCommandId()).getExecutionState());assertEquals(0,h.transport.attempts.get());
assertNotEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceA).getSyncStatus());
}
@Test public void failedNeverAttemptedCommandCanRecover() throws Exception {
h.checkedIn();h.transport.attempts.set(0);h.transport.connected=false;h.auths.clear(h.auth(h.deviceA).getAuthId());LockCommand command=h.latest(h.auth(h.deviceA));
assertEquals(LockConstants.STATUS_FAIL,command.getCommandStatus());assertEquals(ExecutionStates.READY,command.getExecutionState());assertNull(command.getAttemptStartedAt());
h.transport.connected=true;h.commands.retry(command.getCommandId());assertEquals(1,h.transport.attempts.get());h.confirm(command);
assertEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceA).getSyncStatus());
}
@Test public void concurrentNativeRowLocksClaimOnlyOnce() throws Exception {
h.transport.connected=false;h.accommodations.checkIn(h.acc,"round5v4");Long id=h.latest(h.auth(h.deviceA)).getCommandId();h.transport.connected=true;
ExecutorService pool=Executors.newFixedThreadPool(4);
try {
List<Future<?>> tasks=new ArrayList<>();for(int i=0;i<4;i++)tasks.add(pool.submit(()->h.commands.dispatch(id)));
for(Future<?> f:tasks)f.get(20,TimeUnit.SECONDS);
}finally {pool.shutdownNow();}
assertEquals(1,h.transport.attempts.get());h.confirm(h.commandMapper.selectCommandById(id));
}
@Test public void checkinAndIntentRollbackTogetherAfterInjectedFailure() throws Exception {
h.injectBridgeFault("reconcileCheckin");
try{h.accommodations.checkIn(h.acc,"round5v4");fail();}catch(IllegalStateException expected) { }
assertEquals(Integer.valueOf(0),h.trainMapper.selectTrainAccommodationByAccommodationId(h.acc).getAccommodationStatus());
assertNull(h.auth(h.deviceA));assertEquals(0,h.transport.attempts.get());
}
@Test public void checkoutAndRevokeIntentRollbackTogether() throws Exception {
h.checkedIn();LockAuth before=h.auth(h.deviceA);h.injectBridgeFault("reconcileCheckout");
try{h.accommodations.checkOut(h.acc,"round5v4");fail();}catch(IllegalStateException expected) { }
LockAuth after=h.auth(h.deviceA);assertEquals(before.getIntentKey(),after.getIntentKey());assertEquals(ExecutionStates.GRANT,after.getIntentAction());
assertEquals(Integer.valueOf(1),h.trainMapper.selectTrainAccommodationByAccommodationId(h.acc).getAccommodationStatus());
}
@Test public void lateOldRevokeAckCannotOverwriteNewGrantVersion() throws Exception {
h.checkedIn();h.auths.clear(h.auth(h.deviceA).getAuthId());LockCommand old=h.latest(h.auth(h.deviceA));h.confirm(old);
LockAuth grant=h.auth(h.deviceA);h.auths.updateAuth(grant);LockAuth newer=h.auth(h.deviceA);String key=newer.getIntentKey();Long version=newer.getIntentVersion();
h.confirm(old);assertEquals(key,h.auth(h.deviceA).getIntentKey());assertEquals(version,h.auth(h.deviceA).getIntentVersion());assertEquals("0",h.auth(h.deviceA).getStatus());
}
@Test public void mockAndLegacyCommandsAreQuarantinedAcrossRealBoundary() throws Exception {
h.transport.connected=false;h.accommodations.checkIn(h.acc,"round5v4");LockCommand old=h.latest(h.auth(h.deviceA));
h.props.setMock(false);h.props.setEnabled(false);h.props.setRealDeviceWriteEnabled(false);h.props.setDeviceWhitelist(Collections.emptyList());
h.transport.connected=true;h.commands.dispatch(old.getCommandId());
assertEquals(ExecutionStates.QUARANTINED,h.commandMapper.selectCommandById(old.getCommandId()).getExecutionState());assertEquals(0,h.transport.attempts.get());
assertFalse(h.gate.checkDeviceWriteAllowed(old.getDeviceSn(),"0012"));
}
@Test public void uniqueIntentConstraintIsEnforcedByActualKingbase() throws Exception {
h.transport.connected=false;h.accommodations.checkIn(h.acc,"round5v4");LockCommand original=h.latest(h.auth(h.deviceA));
try {
h.tx.required(()->{LockCommand copy=h.commandMapper.selectCommandById(original.getCommandId());copy.setCommandId(null);copy.setCommandNo("R5V4"+UUID.randomUUID().toString().replace("-",""));h.commandMapper.insertCommand(copy);return null;});fail();
}catch(org.springframework.dao.DuplicateKeyException expected) { }
assertEquals(Integer.valueOf(1),h.jdbc.queryForObject("select count(*) from iotlocks.lock_command where intent_key=?",Integer.class,original.getIntentKey()));
}
static class Harness {
final String env;DataSource ds;JdbcTemplate jdbc;SqlSessionTemplate sessions;DataSourceTransactionManager manager;
LockAuthMapper authMapper;LockCommandMapper commandMapper;TrainAccommodationMapper trainMapper;
LockExecutionTransactions tx;IotMqttProperties props;RealIotSecurityGate gate;LockAuthServiceImpl auths;LockCommandServiceImpl commands;
TrainIotLockAuthBridgeServiceImpl bridgeTarget;ITrainLockAuthBridgeService bridge;TrainAccommodationServiceImpl accommodationTarget;ITrainAccommodationService accommodations;
Transport transport;String person,marker,dormA,dormB;long deviceA,deviceB,acc,enrollment;
Harness(String environment) throws Exception {
env=environment;String url=System.getenv("QA_DB_URL");
if(!"1".equals(System.getenv("ROUND5V4_KINGBASE_TESTS")) || url==null || !url.matches("jdbc:kingbase8://192\\.168\\.139\\.218:4321/railwaysystem_fusion_qa_20261013(?:\\?.*)?"))throw new SecurityException("Explicit isolated QA opt-in/URL required");
Class.forName("com.kingbase8.Driver");DriverManagerDataSource raw=new DriverManagerDataSource(url,"fusion_qa_app",System.getenv("QA_DB_PASSWORD"));
ds=new AbstractDataSource(){public Connection getConnection()throws SQLException{Connection c=raw.getConnection();try(Statement s=c.createStatement()){s.execute("set search_path to iotlocks,public,pg_catalog,sys_catalog");s.execute("set lock_timeout='5s'");}return c;}public Connection getConnection(String u,String p)throws SQLException{return getConnection();}};
jdbc=new JdbcTemplate(ds);assertEquals("railwaysystem_fusion_qa_20261013",jdbc.queryForObject("select current_database()",String.class));assertEquals("fusion_qa_app",jdbc.queryForObject("select current_user",String.class));
// Recovery APIs scan pending work. Refuse tests if unrelated active work could be affected.
for(String table:Arrays.asList("lock_auth","lock_command"))
if(jdbc.queryForObject("select count(*) from iotlocks."+table+" where execution_state='READY' and execution_mode<>'LEGACY' and coalesce(environment_id,'') not like 'round5v4-qa-%' and coalesce(environment_id,'')<>?",Integer.class,env)>0)
throw new SecurityException("Unrelated pending QA work exists; recovery test refused");
manager=new DataSourceTransactionManager(ds);tx=new LockExecutionTransactions(manager);
SqlSessionFactoryBean f=new SqlSessionFactoryBean();f.setDataSource(ds);org.apache.ibatis.session.Configuration mc=new org.apache.ibatis.session.Configuration();mc.setDatabaseId("kingbase");f.setConfiguration(mc);f.setTypeAliasesPackage("com.ruoyi.iot.lock.domain;com.ruoyi.train.domain");
List<org.springframework.core.io.Resource> resources=new ArrayList<>();
for(String n:Arrays.asList("LockAuth","LockCommand","LockDevice","LockGateway","LockGroup","LockFace"))resources.add(new ClassPathResource("mapper/lock/"+n+"Mapper.xml"));
for(String n:Arrays.asList("TrainAccommodation","TrainEnrollment","TrainStudent","TrainDormitoryInfo","TrainClassPlan"))resources.add(new ClassPathResource("mapper/train/"+n+"Mapper.xml"));
f.setMapperLocations(resources.toArray(new org.springframework.core.io.Resource[0]));sessions=new SqlSessionTemplate(f.getObject());
authMapper=sessions.getMapper(LockAuthMapper.class);commandMapper=sessions.getMapper(LockCommandMapper.class);trainMapper=sessions.getMapper(TrainAccommodationMapper.class);
props=new IotMqttProperties();props.setMock(true);props.setEnabled(false);props.setRealDeviceWriteEnabled(false);props.setDeviceWhitelist(Collections.emptyList());props.setEnvironmentId(env);restart();
}
@SuppressWarnings("unchecked") <T>T transactional(Object target,Class<T> type){
ProxyFactory p=new ProxyFactory(target);p.setInterfaces(type);p.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));return (T)p.getProxy();
}
void restart() throws Exception {
gate=new RealIotSecurityGate(props);
for(String n:Arrays.asList("device","gateway","group","auth","command")){
Class<?> type=Class.forName("com.ruoyi.iot.lock.mapper.Lock"+Character.toUpperCase(n.charAt(0))+n.substring(1)+"Mapper");inject(gate,n+"Mapper",sessions.getMapper(type));
}
commands=new LockCommandServiceImpl();auths=new LockAuthServiceImpl();transport=new Transport(this);
for(Object s:Arrays.asList(commands,auths)){inject(s,"authMapper",authMapper);inject(s,"commandMapper",commandMapper);inject(s,"securityGate",gate);inject(s,"transactions",tx);}
inject(commands,"mqttService",transport);inject(auths,"deviceMapper",sessions.getMapper(LockDeviceMapper.class));inject(auths,"gatewayMapper",sessions.getMapper(LockGatewayMapper.class));inject(auths,"groupMapper",sessions.getMapper(LockGroupMapper.class));inject(auths,"commandService",commands);
LockFaceServiceImpl faces=new LockFaceServiceImpl();inject(faces,"faceMapper",sessions.getMapper(LockFaceMapper.class));
bridgeTarget=new TrainIotLockAuthBridgeServiceImpl();
inject(bridgeTarget,"executionTransactions",tx);
for(String n:Arrays.asList("TrainAccommodation","TrainEnrollment","TrainStudent","TrainDormitoryInfo","TrainClassPlan"))inject(bridgeTarget,Character.toLowerCase(n.charAt(0))+n.substring(1)+"Mapper",sessions.getMapper(Class.forName("com.ruoyi.train.mapper."+n+"Mapper")));
inject(bridgeTarget,"lockDeviceMapper",sessions.getMapper(LockDeviceMapper.class));inject(bridgeTarget,"lockAuthMapper",authMapper);inject(bridgeTarget,"lockCommandMapper",commandMapper);inject(bridgeTarget,"lockAuthService",auths);inject(bridgeTarget,"lockCommandService",commands);inject(bridgeTarget,"lockFaceService",faces);inject(bridgeTarget,"securityGate",gate);
bridge=transactional(bridgeTarget,ITrainLockAuthBridgeService.class);
accommodationTarget=new TrainAccommodationServiceImpl();
for(String n:Arrays.asList("TrainAccommodation","TrainEnrollment","TrainDormitoryInfo","TrainClassPlan"))inject(accommodationTarget,Character.toLowerCase(n.charAt(0))+n.substring(1)+"Mapper",sessions.getMapper(Class.forName("com.ruoyi.train.mapper."+n+"Mapper")));
accommodationTarget.setAuthEngine("iot");accommodationTarget.setIotLockAuthBridgeService(bridge);accommodations=transactional(accommodationTarget,ITrainAccommodationService.class);
}
void seed(){
String key=UUID.randomUUID().toString().replace("-","");marker="R5V4"+key.substring(0,16);person="P5"+key.substring(0,6);dormA=marker+"A";dormB=marker+"B";
String plan=marker+"P",student=marker+"S";Timestamp start=new Timestamp(System.currentTimeMillis()-86400000),end=new Timestamp(System.currentTimeMillis()+3*86400000L);
jdbc.update("insert into public.train_class_plan(plan_id,plan_name,start_time,end_time,create_by) values(?,?,?,?,?)",plan,"Round5V4 Synthetic",start,end,"round5v4");
jdbc.update("insert into public.train_student(s_id,s_name,person_no,org_name,create_by) values(?,?,?,?,?)",student,"Round5V4 Synthetic",person,"ROUND5V4_SYNTHETIC","round5v4");
enrollment=jdbc.queryForObject("insert into public.train_enrollment(plan_id,student_id,accommodation_required,create_by) values(?,?,1,'round5v4') returning enrollment_id",Long.class,plan,student);
Long group=jdbc.queryForObject("insert into iotlocks.lock_group(group_code,group_name,create_by) values(?,?,'round5v4') returning group_id",Long.class,marker,"Round5V4 Synthetic");
Long gateway=jdbc.queryForObject("insert into iotlocks.lock_gateway(gateway_sn,group_id,create_by) values(?,?,'round5v4') returning gateway_id",Long.class,"G5"+key.substring(0,6),group);
for(int i=0;i<2;i++){
String sn=(i==0?"D5":"D6")+key.substring(0,6);
Long id=jdbc.queryForObject("insert into iotlocks.lock_device(device_sn,device_name,gateway_id,group_id,device_password,dev_ver,dev_type,create_by) values(?,?,?,?,?,'11','19','round5v4') returning device_id",Long.class,sn,"Round5V4 Synthetic",gateway,group,UUID.randomUUID().toString());
if(i==0)deviceA=id;else deviceB=id;
jdbc.update("insert into public.train_dormitory_info(dorm_id,dorm_building,dorm_floor,dorm_room,lock_device_sn,capacity,create_by) values(?,?,'1',?,?,4,'round5v4')",i==0?dormA:dormB,marker,i==0?"A":"B",sn);
}
jdbc.update("insert into iotlocks.lock_face(person_no,person_name,feature_data,feature_md5,extract_status,create_by) values(?,'Round5V4 Synthetic',?,?,'1','round5v4')",person,"mock_feature_round5v4_"+marker,key);
TrainAccommodation a=new TrainAccommodation(enrollment,dormA,"1");a.setCreateBy("round5v4");accommodations.insertTrainAccommodation(a);acc=a.getAccommodationId();
}
LockAuth auth(long device){List<LockAuth> all=authMapper.selectAuthsByPersonNo(person);return all.stream().filter(a->a.getDeviceId()==device).findFirst().orElse(null);}
LockCommand latest(LockAuth a){return commandMapper.selectCommandByIntentKey(authMapper.selectAuthById(a.getAuthId()).getIntentKey());}
void checkedIn(){accommodations.checkIn(acc,"round5v4");confirm(latest(auth(deviceA)));}
void confirm(LockCommand c){ack(c,"10");ack(c,"0");}
void ack(LockCommand c,String code){YdlinkFrameDTO req=YdlinkCodec.fromJson(c.getPayload());YdlinkFrameDTO response=new YdlinkFrameDTO();response.setSeq(c.getCommandNo());response.setOp(req.getOp());response.setFlatParams(Arrays.asList(String.valueOf(req.getParams().get(0)),c.getDeviceSn(),code,"","round5v4-mock"));String body=YdlinkCodec.toJson(response);if("10".equals(code))commands.handleGatewayAck(c.getTopic()+"_report",c.getCommandNo(),body,"Mock only");else commands.handleResponse(c.getTopic()+"_report",c.getCommandNo(),body,true,"Mock only");}
void injectBridgeFault(String method){accommodationTarget.setIotLockAuthBridgeService((ITrainLockAuthBridgeService)Proxy.newProxyInstance(ITrainLockAuthBridgeService.class.getClassLoader(),new Class<?>[]{ITrainLockAuthBridgeService.class},(p,m,a)->{try{Object r=m.invoke(bridge,a);if(m.getName().equals(method))throw new IllegalStateException("synthetic post-intent rollback");return r;}catch(InvocationTargetException e){throw e.getCause();}}));}
int child(String mode,long id)throws Exception{
Path dir=Paths.get(System.getenv("ROUND5V4_LOG_DIR"));Files.createDirectories(dir);Path log=Files.createTempFile(dir,"child-"+mode,".log",PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
Process p=new ProcessBuilder(System.getProperty("java.home")+"/bin/java","-cp",System.getProperty("surefire.test.class.path",System.getProperty("java.class.path")),Round5v4KingbaseIntegrationTest.class.getName(),mode,String.valueOf(id),env).redirectErrorStream(true).redirectOutput(log.toFile()).start();
if(!p.waitFor(40,TimeUnit.SECONDS)){p.destroyForcibly();throw new AssertionError("Isolated child timeout");}return p.exitValue();
}
}
static void inject(Object target,String name,Object value)throws Exception{Field f=target.getClass().getDeclaredField(name);f.setAccessible(true);f.set(target,value);}
static class Transport implements IMqttService {
final Harness h;boolean connected=true,crashAfterClaim;AtomicInteger attempts=new AtomicInteger();Transport(Harness harness){h=harness;}
public boolean isConnected(){return connected;}
public void publishCommand(LockCommand c){h.gate.requirePersistedDispatch(c);assertEquals(ExecutionStates.DISPATCHING,h.jdbc.queryForObject("select execution_state from iotlocks.lock_command where command_id=?",String.class,c.getCommandId()));if(crashAfterClaim)Runtime.getRuntime().halt(74);attempts.incrementAndGet();}
public void publish(String t,String p){throw new AssertionError("Raw publication forbidden");}public void publish(String t,Object p,Integer q,Boolean r){throw new AssertionError("Raw publication forbidden");}
public void subscribe(String t){}public void subscribe(String t,Integer q){}public void unsubscribe(String t){}public void reconnect(){}public Map<String,Integer> getSubscriptions(){return Collections.emptyMap();}
}
/** Separate process: committed database intent/claim survives Runtime.halt without touching any server process. */
public static void main(String[] args)throws Exception{
Harness h=new Harness(args[2]);long id=Long.parseLong(args[1]);
if(args[0].equals("intent")){
ILockAuthService crashing=(ILockAuthService)Proxy.newProxyInstance(ILockAuthService.class.getClassLoader(),new Class<?>[]{ILockAuthService.class},(p,m,a)->{if(m.getName().equals("clear"))Runtime.getRuntime().halt(73);try{return m.invoke(h.auths,a);}catch(InvocationTargetException e){throw e.getCause();}});
inject(h.bridgeTarget,"lockAuthService",crashing);h.accommodations.checkOut(id,"round5v4");
}else if(args[0].equals("ready")){h.transport.connected=false;h.auths.clear(id);Runtime.getRuntime().halt(75);}
else if(args[0].equals("claim")){h.transport.crashAfterClaim=true;h.auths.clear(id);}
throw new AssertionError("Crash injection was not reached");
}
}
@@ -114,6 +114,13 @@ public class TrainIotLockAuthBridgeMockTest
// 组装桥接服务
bridgeService = new TrainIotLockAuthBridgeServiceImpl();
// This in-memory fixture does not prove database transaction semantics.
com.ruoyi.iot.lock.service.LockExecutionTransactions transactions = new com.ruoyi.iot.lock.service.LockExecutionTransactions(new org.springframework.transaction.PlatformTransactionManager() {
public org.springframework.transaction.TransactionStatus getTransaction(org.springframework.transaction.TransactionDefinition d) { return new org.springframework.transaction.support.SimpleTransactionStatus(); }
public void commit(org.springframework.transaction.TransactionStatus s) { }
public void rollback(org.springframework.transaction.TransactionStatus s) { }
});
setField(bridgeService, "executionTransactions", transactions);
com.ruoyi.iot.config.IotMqttProperties executionProps = new com.ruoyi.iot.config.IotMqttProperties();
executionProps.setMock(true); executionProps.setEnvironmentId("bridge-fixture-mock");
com.ruoyi.iot.lock.security.RealIotSecurityGate executionGate = new com.ruoyi.iot.lock.security.RealIotSecurityGate(executionProps);
@@ -73,14 +73,14 @@
<el-tag v-else size="small">{{ scope.row.accommodationStatus }}</el-tag>
</template>
</el-table-column>
<el-table-column label="入住时间" align="center" prop="checkInTime" width="150">
<el-table-column label="分配时间" align="center" prop="assignedTime" width="150">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.checkInTime, '{y}-{m}-{d} {h}:{i}') }}</span>
<span>{{ parseTime(scope.row.assignedTime, '{y}-{m}-{d} {h}:{i}') }}</span>
</template>
</el-table-column>
<el-table-column label="退宿时间" align="center" prop="checkOutTime" width="150">
<el-table-column label="最近状态变更" align="center" prop="updateTime" width="150">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.checkOutTime, '{y}-{m}-{d} {h}:{i}') }}</span>
<span>{{ parseTime(scope.row.updateTime, '{y}-{m}-{d} {h}:{i}') }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" min-width="180">
@@ -187,14 +187,14 @@
<el-tag v-else size="mini">{{ scope.row.accommodationStatus }}</el-tag>
</template>
</el-table-column>
<el-table-column label="入住时间" align="center" width="150">
<el-table-column label="分配时间" align="center" width="150">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.checkInTime, '{y}-{m}-{d} {h}:{i}') }}</span>
<span>{{ parseTime(scope.row.assignedTime, '{y}-{m}-{d} {h}:{i}') }}</span>
</template>
</el-table-column>
<el-table-column label="退宿/调离时间" align="center" width="150">
<el-table-column label="最近状态变更" align="center" width="150">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.checkOutTime, '{y}-{m}-{d} {h}:{i}') }}</span>
<span>{{ parseTime(scope.row.updateTime, '{y}-{m}-{d} {h}:{i}') }}</span>
</template>
</el-table-column>
</el-table>
+3
View File
@@ -1,5 +1,8 @@
# Round 5V-3 — SSH接管、原生备份完成,独立恢复仍待解阻
历史记录保持原状。后续 Round 5V-4 已另获新建隔离恢复容器授权,实际恢复、005演练/回滚及源QA迁移均已完成;
当前结论见 [Round 5V-4 软件验收](ROUND5V4_SOFTWARE_QA.md),下文的“尚未恢复/未迁移”仅指当时状态。
日期:2026-10-14;工作分支 `fix/iot-safety-hardening`;开始提交
`b94ac7f05aef80e092fddcf3d6ff3ac0a1a890b3`。本轮没有重新运行H2或源码安全审计。
凭据仅用于SSH认证输入,未写入工程、命令行参数、报告或凭据文件。
+179
View File
@@ -0,0 +1,179 @@
# Round 5V-4 — Kingbase 隔离恢复、005 迁移与软件融合 QA
执行日期:2026-10-14。工作树:`boot4-iot-fusion-archive`;分支:`fix/iot-safety-hardening`。
本轮基线:`ba5dc7a6af9e6bb6b5166ef04d49af621c4f8de2`。
GitLab 已归档融合基线仍为 `feat/boot4-iot-fusion@37392144`;本轮没有 Push、Merge 或共享部署。
**收尾状态(18:27之后):BLOCKED_NETWORK,不声明当前 SOFTWARE_QA_READY=YES。**
下列迁移、11项真实Kingbase测试、15路由及浏览器业务闭环在17:35前已实际通过并保留证据。
之后数据库连接中断(后端首条记录17:50:55,SQLSTATE08001;后续EOF),18:27独立TCP探测
192.168.139.218:22和:4321均超时。无法仅凭本机证据区分服务器离线、链路或路由原因,
未修改Mac/服务器网络、代理、服务或数据库配置。已停止本轮本机QA前端/后端/Redis,
不把端口仍监听当成数据库可用。恢复合法连接后只需重新预检、启动与关键读验收,
**005已经成功提交,不要因为断网而重复初始化、覆盖恢复或重做迁移。**
## 1. 授权与实际边界
本轮用户另行批准在 192.168.139.218 新建离线、资源受限 V9 临时恢复容器及恢复库;
只有原生备份恢复、005 演练及失败恢复通过,才能迁移独立 QA 库。
所有 Kingbase 业务写入均限定 `railwaysystem_fusion_qa_20261013`,或本轮新隔离容器中的专用恢复库。
未修改共享 `training`、独立 `iotlocks` 等数据库;目标 QA 内的 `iotlocks` 是 Schema,不是另一个数据库。
未扩大业务账号权限、修改系统网络、控制 MQTT/人脸/门锁设备、重启共享服务。
## 2. 镜像、许可、资源和共享服务
服务器只读确认:root UID0,hostname `localhost.localdomain`,NFSChina Server 4.0,
128 CPU、512 GiB 内存、可用约 372000 MiB,磁盘剩余约 535 GiB,负载低。
现有 `kingbase` 容器 `dcd88030ca5a`,4321→54321,版本 `KingbaseES V009R003C018`;
原挂载 `/opt/kingbase-docker/data` 未修改或复用。原生工具目录 `/home/kingbase/install/kingbase/bin/`。
仅复用已存在镜像 `kingbase_v009r003c018b0003_single_x86:v1`:
`sha256:540fa4d0c8f365c8d363a801404e93f8401c7bc5035ecd7b621f0dba862f2515`。
宿主机已有导入 tar 的 manifest/config 散列与 Docker image ID 相符;无 RepoDigest,
未拉取新镜像,不声称额外验证了供应商签名或生产供应链来源。
从镜像原始层读取并用原生许可工具检查:V009 企业版试用授权,180天,容器名/MAC不绑定,
未见 vCPU 限额;未改写许可、复制生产凭据或连接许可服务器。新服务实际通过原生启动许可校验。
这只证明现有获准 QA/试用范围可启动,不代表长期或生产部署许可。
参考:[Kingbase Docker 安装与许可检查](https://bbs.kingbase.com.cn/kingbase-html/v9/install-updata/install-docker/install-docker-1.html)。
新容器 `railwaysystem-qa-restore-round5v4`:
`c11ca10490c242651c0e87c2778102cd9de32cd703b48b57c26801686d4ef41f`。
实际运行过;Docker inspect 与 cgroup 实测确认 network=none、只有 lo、路由为空、
PortBindings={}、2 CPU、2 GiB memory+swap总额、pids=256、privileged=false、drop NET_RAW,
没有 Docker Socket 或正式数据卷。唯一新数据挂载:
`/opt/railwaysystem-qa-restore-round5v4/data` → `/home/kingbase/userdata`。
创建前检查 realpath、符号链接、名称和挂载冲突;专用随机凭据仅在 root0700 目录中的0600文件。
存储不是 5 GiB 硬配额。按用户允许的替代条件,独立监护进程每2秒统计任务目录、overlay及日志,
达到4 GiB或宿主机空闲低于100 GiB即核验容器身份后停止;日志有轮转上限。
实际观测用量约219 MB,远低于阈值;没有 OOM。监护实现:`scripts/qa/round5v4_storage_guard.py`。
共享 Kingbase/GitLab 的容器 ID 和启动时间一直未变(均为2026-10-06),GitLab healthy,其他服务持续运行。
本轮恢复完成后仅停止上述新容器;不删除其数据、恢复证据或备份。
首次Docker stop因镜像入口未响应而超时退出137(非OOM);仅重启该新实例后,
实际复核恢复库和原备份恢复库全量清单仍一致,再用原生sys_ctl fast正常停库(exit0),
sys_controldata确认cluster state=shut down,最后停止容器入口。没有重启共享实例。
## 3. 原生备份与实际恢复(不是文件读取替代)
备份源:`railwaysystem_fusion_qa_20261013`;原生 V9 `sys_dump` 自定义归档,
此前同一导出快照的63表清单/指纹,与本轮迁移前源库及新恢复库再次逐项匹配。
远端:`/opt/railway-fusion-qa-backup-20261014.YLjmJ0/fusion-qa-pre005-v2.dump`;
本机:`/Users/wzb/railway-fusion-qa-backup-20261014.6cgPn7/fusion-qa-pre005-v2.dump`。
380907字节,SHA256:`caa62bc985bd433b5d428b36e34d684b557c82576b69e5e5d9d348edfdf316b4`。
两份均在 Git 外受保护目录,原备份未改写、未删除。
在新实例创建 `railwaysystem_fusion_restore_round5v4`,用匹配版本 `sys_restore -O -x -1 -e`
实际恢复成功,退出0,stderr为空。Owner 映射到仅在新实例存在的 `round5v4_admin`,
不恢复源库认证配置/ACL;此为明确的恢复映射,而非遗漏生产认证。
实测恢复对象:public54表 + iotlocks9表;所有63表行数及排序行指纹一致;
892列、146索引、113约束、39序列、20扩展及Schema/默认值/触发器清单完成对账。
旧授权10条、旧指令15条,培训学员47、参训57、住宿34条。
唯一 native dump/reparse 语法差异是 `chk_external_operation_status` 的数组/元素 text cast:
只对这一个精确约束做规范化,另用原生SQL对10个允许值、4个非法值及NULL共15例验证语义无差异。
其他元数据不宽泛忽略。恢复后基本只读查询通过。
清单生成器:`scripts/qa/kingbase_manifest.sql`;受保护证据包含原生清单和JSON摘要。
## 4. 005 的隔离演练、回滚与源 QA 执行
005 SHA256:`90648b6ac67470f9a14f0795c4479dfb4811d4907e41a13ee5eeada16582b87f`。
审计完整20条语句:16项新增列、4项新增索引(含唯一意图索引);无DROP/TRUNCATE/DELETE/覆写业务数据。
隔离 V9 实际通过:
| 场景 | 实际结果 |
|---|---|
| 迁移中故意调用不存在函数 | SQLSTATE42883;全量DDL回滚,原结构和数据指纹一致 |
| 客户端在未提交005后断开 | 服务器自动回滚;结构/指纹一致 |
| 另一连接持读锁,DDL等锁500ms | SQLSTATE55P03;安全回滚,未破坏数据 |
| 005正常COMMIT | 16列/4索引、默认值、旧记录隔离均正确 |
| 同一005重复执行 | 全量结构/数据清单不变 |
| 重复意图键 | 原生唯一约束23505,事务回滚 |
| 从原备份进行失败恢复 | 在同一新容器内另建专用 recovery 库并实际恢复,完整清单与迁移前一致 |
上述为真实数据库的事务、连接中断及恢复验证;另有收尾时仅临时实例非优雅停止后的重启完整性复核,
不是对共享/源QA数据库服务器断电作测试,也不是完整灾难恢复/长期故障注入认证。
没有在源QA库上删除列/表或全库覆盖演练。
之后重新核验源数据库名/Schema、原备份SHA、63表清单均未变化、其他源库连接数0;
使用已有管理角色,不提权业务账号。设置 lock_timeout=3s、statement_timeout=30s,
显式事务及两表ACCESS EXCLUSIVE锁,在锁内再次核对原行指纹,执行相同005并COMMIT。
源 QA 的后验清单与隔离迁移后清单完全对应。旧10授权/15命令为 LEGACY/QUARANTINED、
version0、无来源环境/意图key,未转换为REAL。业务验收后再次过滤LEGACY核对其完整原记录指纹,仍一致。
一次性执行/核验代码:`scripts/qa/round5v4_database_stage.py`。不要对已在使用的QA库重跑该一次性编排。
`fusion_qa_app` 真实连接成功,现有SELECT/INSERT/UPDATE及序列权限足够全部业务测试;
无superuser/createdb/createrole、无schema CREATE。既有 sys_menu/sys_notice_read/sys_role_menu
三表仍有TRUNCATE/TRIGGER/REFERENCES旧授权:本轮未使用、未扩大,也未擅自收回,建议管理员另行最小化。
## 5. 真实 Kingbase Service / Mapper / 事务与故障恢复
`Round5v4KingbaseIntegrationTest`:11项真实Kingbase测试,使用生产XML、Service、
DataSourceTransactionManager及真实AOP事务代理;账号固定 fusion_qa_app;URL强校验仅允许目标QA库。
外部仅替换 MQTT 传输,未加载真实MQTT或人脸HTTP客户端。恢复扫描前拒绝无关READY工作。
合成 R5V4 数据保留审计,不删除原数据,不重置序列;用例后仅关闭自己的未尝试任务。
覆盖:入住、调宿撤旧建新、退宿意图;意图提交/命令创建前、命令创建/发送前、
DISPATCHING占用提交/传输结果未知三种独立JVM `Runtime.halt`,再创建新Service读取持久化状态恢复;
UNKNOWN转MANUAL_REVIEW且零盲目重发;未尝试的FAIL撤权可补偿;4线程原生行锁仅发送一次;
唯一意图冲突;入住和退宿故障注入时住宿状态与意图同事务回滚;旧撤权ACK不覆盖新授权;MOCK到REAL边界隔离。
数据库提交、Mock发送、双Mock回执均分别断言;不等于设备最终执行。
真实测试发现并修复此前未暴露的事务边界缺陷:桥接 AFTER_COMMIT 调用 REQUIRED 时仍可能
加入已提交但尚未清理的事务,命令滞留READY。现在桥接回调通过 `LockExecutionTransactions.committed`
明确在 REQUIRES_NEW 中生成命令,再由它自己的提交回调占用/发送;业务原子性仍留在原事务内。
修改位于 `TrainIotLockAuthBridgeServiceImpl.dispatchPushSafe/dispatchClearSafe`。
另通过85项选定后端回归、新增1项Boot4缺路由处理回归、12项QA工具测试及前端生产构建,
不把这些替代真实数据库结论。
## 6. 实际后端与 Chrome 验收
当前归档工程新进程,Java21.0.2 / Boot4.1.0,127.0.0.1:18096;生产前端127.0.0.1:18088;
独立Redis127.0.0.1:16389,口令随机、无持久化。不复用实验PID51901/8096或其他工程PID53305/8080。
运行JAR有SHA绑定私有副本;停机核验账本/PID出生时间/命令/cwd并等待退出,无粗暴清端口。
有效参数强制:mqtt.enabled=false、mock=true、real-device-write-enabled=false、whitelist空、
protocol-verified=false、retry-task-enabled=false、QA/OpenAPI关闭、人脸HTTP关闭且Mock、DMS关闭、Quartz不自动运行。
数据库用户名/URL固定QA,秘密只从本机0600配置及私有随机运行配置注入。
Kingbase/Druid实际探针发现URL中的socketTimeout=15被池解析为15ms;启动器移除URL超时并明确池60000ms,
同一探针实际验证60000ms。参考 [Druid 1.2.28 原始实现](https://github.com/alibaba/druid/blob/1.2.28/core/src/main/java/com/alibaba/druid/pool/DruidAbstractDataSource.java)。
首页Kingbase分析SQL的 `::numeric` 改为等价标准CAST,消除Druid统计SQL解析异常;没有关闭SQL安全检查。
负向运行检查还发现被禁用的MQTT/QA端点会被旧全局处理器误报成HTTP200/业务500;
新增对Boot4 NoResourceFoundException的脱敏404处理,并以单元及真实HTTP复验,不注册或开放控制接口。
真实 Google Chrome155.0.8059.40(headed)管理员登录成功,getInfo/getRouters=200;
现有QA验证码配置返回 captchaEnabled=false,未改开关,不声称完成启用状态验证码挑战。
15路由逐页通过组件名、实际表格/图表、HTTP/业务code、JS错误检查:
首页Index/AdminDashboard四张真实ECharts、学员Student、教师Teacher、开班Plan、宿舍Dormitory、动态菜单住宿Accommodation,
以及9个IoT组件Group/Gateway/Device/Face/Auth/AccessLog/Command/MqttLog/ServiceConfig。
最后一轮浏览器请求仅出现127.0.0.1:18088,无外部主机;浏览器设有外发拒绝保护,未发生外部请求阻断事件。
品牌/Logo/侧边栏/动态菜单保留,无404/登录替身页;17:35前最终验收期间后端无SQL或未捕获异常。
后续网络连接异常见本报告顶部,不能将该时点的成功外推为当前在线状态。
浏览器合成 fixture `R5V4463c944c9547465b`,学号后缀S、人员P5463c94:
预分配2043 → 真UI入住 → Mock grant确认 → 真UI调房到B/新住宿2044 → 旧权revoke+新权grant确认
→ 真UI退宿 → revoke确认;两个授权1035/1036最终REVOKE版本2、CONFIRMED、sync4;
四条0011/0012命令均MOCK、CONFIRMED、网关/设备双ACK,住宿两条已退宿;UI与数据库再次一致核对。
离线回执驱动只接受该fixture的当前MOCK命令,危险HTTP回执入口未启用。
住宿履历和指令流水在真实Chrome中查看,不是仅调用API或替身页面。
前端旧页面引用模型不存在的checkInTime/checkOutTime,已改为真实assignedTime/updateTime,
标签为“分配时间/最近状态变更”;无额外DDL,不将updateTime冒充精确入住/退宿专用时间戳。
原生备份、数据库/浏览器运行日志和截图仅保存在受保护Git外/忽略目录,不提交个人资料。
## 7. 证据位置、复现与剩余边界
- 远端专用根:`/opt/railwaysystem-qa-restore-round5v4/`,含原生恢复输出、结构/行指纹、
isolated-results.json、source-migration-results.json、container-safety.json、historical-preservation.json、空间监护日志。
- 本机受保护忽略目录 `.qa-runtime/round5v4/`:同名JSON、真实Kingbase JUnit/子JVM日志、
后端/前端构建、浏览器15页结果和闭环数据验证;JUnit XML在ruoyi-train/target/surefire-reports。
- `output/playwright/`:最终首页、IoT九页(敏感数据行遮罩)、合成住宿履历/退宿/授权/指令证据;均不入Git。
- 具体构建、隔离启停、Kingbase选定测试及浏览器夹具流程见根 `DEMO_RUNBOOK.md`。
软件功能QA已通过,但当前可运行就绪状态受上述网络问题阻塞,更不代表生产发布或真实硬件验收。
仍须老师明确批准单台测试、确认真实协议校验字段/回执语义、
提供现场设备与单台白名单、核对UNKNOWN人工处置流程;门禁保持关闭。
Vue2/CLI4废弃依赖、旧历史素材/外部凭据轮换、既有账号过宽权限及长期商业许可仍须单独治理。
本轮没有远程CI/部署验证,没有数据库服务器断电测试,没有真实IoT执行证据。
+10 -1
View File
@@ -1,6 +1,15 @@
# Round 5V — isolated Kingbase migration and rollback gate
## Current Round 5V-3 decision
## Current Round 5V-4 outcome
The separately authorized new offline V9 recovery container actually restored the native backup;
005 rehearsal, repeatability, transactional rollback and fresh-target backup recovery passed.
Only afterwards, the exact approved 16-column/4-index script committed on the named source QA database.
Real Kingbase service/transaction/process-interruption tests and Chrome software acceptance now passed.
See [Round 5V-4 evidence](ROUND5V4_SOFTWARE_QA.md). Do not rerun initialization or overwrite a live database.
The following sections retain the pre-migration plan/observations, not current missing-approval claims.
## Historical Round 5V-3 decision
The user explicitly authorized migration 005 **only** on
`railwaysystem_fusion_qa_20261013`, together with protected backup and isolated
+16
View File
@@ -0,0 +1,16 @@
import com.alibaba.druid.pool.DruidDataSource;
import java.sql.*;
/** Read-only actual Kingbase/Druid initialization probe, secrets only from environment. */
class QaDruidProbe {
public static void main(String[] args)throws Exception {
String url=System.getenv("QA_DB_URL");
if(url==null || !url.matches("jdbc:kingbase8://192\\.168\\.139\\.218:4321/railwaysystem_fusion_qa_20261013(?:\\?.*)?"))throw new SecurityException();
try(DruidDataSource ds=new DruidDataSource()) {
ds.setDriverClassName("com.kingbase8.Driver");ds.setUrl(url);ds.setUsername("fusion_qa_app");ds.setPassword(System.getenv("QA_DB_PASSWORD"));
ds.setInitialSize(1);ds.setMaxActive(2);ds.setMinIdle(1);ds.setConnectTimeout(30000);ds.setSocketTimeout(60000);
if(args.length>0)ds.setConnectionProperties(args[0]);
ds.setValidationQuery("select 1");ds.setConnectionInitSqls(java.util.List.of("set search_path to iotlocks,public,pg_catalog,sys_catalog"));
ds.init();try(Connection c=ds.getConnection()){c.setReadOnly(true);c.setAutoCommit(false);try(Statement s=c.createStatement();ResultSet r=s.executeQuery("select current_database(),current_user")){r.next();System.out.println(r.getString(1)+" | "+r.getString(2)+" | network_timeout_ms="+c.getNetworkTimeout());}c.rollback();}
}
}
}
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""Explicit synthetic fixture/Mock ACK driver; no QA HTTP endpoints or real device transport."""
import argparse
import os
from pathlib import Path
import subprocess
import xml.etree.ElementTree as ET
import qa
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('action',choices=['seed','ack','state','verify'])
parser.add_argument('--approve-synthetic-qa-writes',action='store_true',required=True)
parser.add_argument('--config',type=Path,default=Path.home()/'.railwaysystem-qa.env')
args=parser.parse_args()
env=qa.environment(qa.load_config(args.config));qa.private_directory()
directory=qa.RUNTIME/'round5v4';directory.mkdir(mode=0o700,exist_ok=True)
env.update(ROUND5V4_KINGBASE_TESTS='1',ROUND5V4_LOG_DIR=str(directory))
report=qa.ROOT/'RuoYi-Vue/ruoyi-train/target/surefire-reports/TEST-com.ruoyi.train.integration.lock.Round5v4KingbaseIntegrationTest.xml'
tree=ET.parse(report);cp=tree.find("./properties/property[@name='surefire.test.class.path']").attrib['value']
command=['java','-cp',cp,'com.ruoyi.train.integration.lock.Round5v4BrowserFixture',args.action]
result=subprocess.run(command,cwd=qa.ROOT,env=env,text=True,capture_output=True,timeout=90)
with open(directory/('browser-fixture-'+args.action+'.log'),'w',opener=lambda p,f:os.open(p,f,0o600)) as log:
log.write(result.stdout+result.stderr)
for line in result.stdout.splitlines():
if line.startswith(('BROWSER_FIXTURE_CREATED','FIXTURE_AUTH','FIXTURE_COMMAND','FIXTURE_BROWSER_DATABASE')):print(line)
print('FIXTURE_DRIVER_EXIT='+str(result.returncode))
return result.returncode
if __name__=='__main__':
raise SystemExit(main())
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Log into the task's Chrome QA session without credentials in CLI arguments, code, or logs.
The protected configuration is read through File API on a separate about:blank page,
never uploaded to the application; only two login values are filled, solely in memory.
Requires the Playwright CLI skill and an already-open isolated round5v4 Chrome session.
"""
import argparse
import json
from pathlib import Path
import subprocess
import qa
CODE = r'''async (page) => {
if(!USE_CURRENT_PAGE){await page.context().clearCookies();await page.goto('http://127.0.0.1:18088/login');}
await page.getByRole('textbox',{name:'账号',exact:true}).waitFor();
await page.waitForLoadState('networkidle');
page.__qaEvidence={responses:[],errors:[]};
if(page.__qaErrorListener)page.removeListener('pageerror',page.__qaErrorListener);
if(page.__qaResponseListener)page.removeListener('response',page.__qaResponseListener);
page.__qaErrorListener=error=>page.__qaEvidence.errors.push(error.message);
page.__qaResponseListener=async response=>{const url=response.url();if(!url.includes('/prod-api/'))return;let code=null;try{code=(await response.json()).code;}catch{}page.__qaEvidence.responses.push({path:url.split('/prod-api')[1],status:response.status(),code});};
page.on('pageerror',page.__qaErrorListener);page.on('response',page.__qaResponseListener);
const local=await page.context().newPage();let credentials={};
try {
await local.goto('about:blank');await local.setContent('<input id="protected-config" type="file">');
await local.locator('#protected-config').setInputFiles(CONFIG_PATH);
credentials=await local.locator('#protected-config').evaluate(async input=>{
const text=await input.files[0].text();const vals={};
for(const line of text.split('\n')){const m=line.match(/^(?:export )?(QA_ADMIN_USERNAME|QA_ADMIN_PASSWORD)=(.*)$/);if(m){let v=m[2].trim();if((v.startsWith("'")&&v.endsWith("'"))||(v.startsWith('"')&&v.endsWith('"')))v=v.slice(1,-1);vals[m[1]]=v;}}
return vals;
});
}finally{await local.close();}
if(!credentials.QA_ADMIN_USERNAME||!credentials.QA_ADMIN_PASSWORD)throw new Error('Protected QA login configuration missing');
if(await page.getByRole('textbox',{name:'验证码',exact:true}).isVisible()){
if(!CAPTCHA_ANSWER)throw new Error('Captcha enabled: complete visual captcha manually; do not disable it');
await page.getByRole('textbox',{name:'验证码',exact:true}).fill(CAPTCHA_ANSWER);
}
await page.getByRole('textbox',{name:'账号',exact:true}).fill(credentials.QA_ADMIN_USERNAME);
await page.getByRole('textbox',{name:'密码',exact:true}).fill(credentials.QA_ADMIN_PASSWORD);credentials={};
await page.getByRole('button',{name:'登 录'}).click();await page.waitForURL(url=>url.origin==='http://127.0.0.1:18088'&&!['/login','/404','/401'].includes(url.pathname),{timeout:20000});await page.waitForLoadState('networkidle');
return {url:page.url(),title:await page.title(),api:page.__qaEvidence.responses,errors:page.__qaEvidence.errors};
}'''
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--config',type=Path,default=Path.home()/'.railwaysystem-qa.env')
parser.add_argument('--cli',type=Path,default=Path.home()/'.codex/skills/playwright/scripts/playwright_cli.sh')
parser.add_argument('--captcha',default='',help='Answer from the actual displayed captcha, never disable validation')
parser.add_argument('--use-current-page',action='store_true')
args=parser.parse_args();qa.load_config(args.config)
code=CODE.replace('CONFIG_PATH',json.dumps(str(args.config.expanduser().resolve()))).replace('CAPTCHA_ANSWER',json.dumps(args.captcha)).replace('USE_CURRENT_PAGE',str(args.use_current_page).lower())
result=subprocess.run([str(args.cli),'-s=round5v4','run-code',code],text=True,capture_output=True,timeout=45)
# The emitted code contains a path, never credential values. Do not snapshot a filled login form.
print(result.stdout)
return result.returncode
if __name__=='__main__':raise SystemExit(main())
+41
View File
@@ -0,0 +1,41 @@
async (page) => {
if (!page.__qaEvidence) throw new Error('Run the protected Chrome login driver first');
const blockedHosts=new Set(),requestHosts=new Set();
// Real pages/API only; never substitute responses or allow a stored photo URL to contact a device service.
await page.route('**/*',async route=>{
const url=route.request().url(),host=url.match(/^https?:\/\/([^/]+)/)?.[1];
if(host)requestHosts.add(host);
if(host&&host!=='127.0.0.1:18088'){blockedHosts.add(host);await route.abort('blockedbyclient');}
else await route.continue();
});
const routes = [
['/index', null], ['/person/student', 'Student'], ['/person/teacher', null],
['/coursemgr/plan', null], ['/equip/dormitory', null], ['/equip/accommodation', 'Accommodation'],
...Object.entries({group:'Group',gateway:'Gateway',device:'Device',face:'Face',auth:'Auth',
accesslog:'AccessLog',command:'Command',mqttlog:'MqttLog',config:'ServiceConfig'}).map(([r,c])=>['/iot/'+r,c])
];
const results=[];
for (const [path,expected] of routes) {
const start=page.__qaEvidence.responses.length;
await page.goto('http://127.0.0.1:18088'+path);
await page.waitForLoadState('networkidle');
const components=await page.locator('.app-main').evaluate(e=>{
function names(v){return v?[v.$options.name||v.$options._componentTag||'',...v.$children.flatMap(names)]:[];}
return names(e.__vue__).filter(n=>n&&!n.startsWith('El'));
});
const api=[...new Map(page.__qaEvidence.responses.slice(start).map(r=>[JSON.stringify(r),r])).values()];
const tables=await page.locator('.app-main .el-table').count();
const pass=page.url()==='http://127.0.0.1:18088'+path&&
(await page.title()).includes('SYFJGYD')&&(!expected||components.includes(expected))&&
!components.some(n=>/404|401|ErrorPage/i.test(n))&&api.length>0&&
api.every(r=>r.status===200&&(r.code==null||r.code===200))&&page.__qaEvidence.errors.length===0&&blockedHosts.size===0&&
(path==='/index'?await page.locator('canvas').count()===4:tables>0);
results.push({path,pass,components:[...new Set(components)],tables,api});
if(!pass)throw new Error('Actual component/API regression failed: '+JSON.stringify(results.at(-1)));
if(path==='/index')await page.screenshot({path:'output/playwright/round5v4-home-final.png',fullPage:true});
if(path.startsWith('/iot/'))await page.screenshot({path:'output/playwright/round5v4-'+path.split('/').at(-1)+'-headers.png',
mask:[page.locator('.el-table__body-wrapper'),page.locator('.avatar-container')]});
}
return {pass:true,chrome:page.context().browser().version(),results,errors:page.__qaEvidence.errors,
requestHosts:[...requestHosts],blockedExternalHosts:[...blockedHosts]};
}
+35
View File
@@ -0,0 +1,35 @@
\set ON_ERROR_STOP on
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SET LOCAL statement_timeout='30s';
SET LOCAL lock_timeout='5s';
SET LOCAL timezone='UTC';
SET LOCAL search_path=pg_catalog,sys_catalog,public,iotlocks;
SELECT current_database() IN ('railwaysystem_fusion_qa_20261013','railwaysystem_fusion_restore_round5v4','railwaysystem_fusion_recovery_round5v4') AS allowed_database
\gset
\if :allowed_database
\else
\echo REFUSING_UNAPPROVED_DATABASE
\quit 3
\endif
SELECT 'IDENTITY',current_database(),current_user,version();
SELECT 'SCHEMA',nspname FROM pg_namespace WHERE nspname IN ('public','iotlocks') ORDER BY nspname;
SELECT 'TABLE',schemaname,tablename FROM pg_tables WHERE schemaname IN ('public','iotlocks') ORDER BY schemaname,tablename;
SELECT format('SELECT %L,count(*),md5(coalesce(string_agg(row_to_json(t)::text,%L ORDER BY row_to_json(t)::text),%L)) FROM %I.%I t;',concat('DATA:',schemaname,'.',tablename),'','',schemaname,tablename)
FROM pg_tables WHERE schemaname IN ('public','iotlocks') ORDER BY schemaname,tablename
\gexec
SELECT 'LEGACY_DATA:lock_auth',count(*),md5(coalesce(string_agg((to_jsonb(t)-ARRAY['execution_mode','environment_id','intent_key','intent_version','execution_state','intent_action','intent_at'])::text,'' ORDER BY auth_id),'')) FROM iotlocks.lock_auth t;
SELECT 'LEGACY_DATA:lock_command',count(*),md5(coalesce(string_agg((to_jsonb(t)-ARRAY['execution_mode','environment_id','intent_key','intent_version','execution_state','intent_action','intent_at','device_sn','attempt_started_at'])::text,'' ORDER BY command_id),'')) FROM iotlocks.lock_command t;
SELECT 'COLUMN',table_schema,table_name,column_name,ordinal_position,data_type,is_nullable,coalesce(column_default,''),coalesce(character_maximum_length::text,'')
FROM information_schema.columns WHERE table_schema IN ('public','iotlocks') ORDER BY table_schema,table_name,ordinal_position;
SELECT 'INDEX',schemaname,tablename,indexname,indexdef FROM pg_indexes WHERE schemaname IN ('public','iotlocks') ORDER BY schemaname,tablename,indexname;
SELECT 'CONSTRAINT',n.nspname,t.relname,c.conname,c.contype,pg_get_constraintdef(c.oid)
FROM pg_constraint c JOIN pg_class t ON c.conrelid=t.oid JOIN pg_namespace n ON n.oid=t.relnamespace
WHERE n.nspname IN ('public','iotlocks') ORDER BY n.nspname,t.relname,c.conname;
SELECT 'TRIGGER',n.nspname,t.relname,g.tgname,pg_get_triggerdef(g.oid)
FROM pg_trigger g JOIN pg_class t ON t.oid=g.tgrelid JOIN pg_namespace n ON n.oid=t.relnamespace
WHERE n.nspname IN ('public','iotlocks') AND NOT g.tgisinternal ORDER BY n.nspname,t.relname,g.tgname;
SELECT 'SEQUENCE',schemaname,sequencename,start_value,min_value,max_value,increment_by,cycle,cache_size,last_value
FROM pg_sequences WHERE schemaname IN ('public','iotlocks') ORDER BY schemaname,sequencename;
SELECT 'EXTENSION',extname,extversion FROM pg_extension ORDER BY extname;
SELECT 'OWNER',schemaname,tablename,tableowner FROM pg_tables WHERE schemaname IN ('public','iotlocks') ORDER BY schemaname,tablename;
ROLLBACK;
+40 -3
View File
@@ -9,10 +9,12 @@ from pathlib import Path
import secrets
import shlex
import signal
import shutil
import socket
import stat
import subprocess
import sys
import time
import urllib.parse
import xml.etree.ElementTree as ET
@@ -95,6 +97,15 @@ def available(port):
probe.bind(('127.0.0.1', port))
def druid_url(url):
# Druid 1.2.28 also parses URL timeout values as pool milliseconds, whereas
# Kingbase JDBC uses seconds. Keep pool timeout configuration in one place.
parsed = urllib.parse.urlsplit(url.removeprefix('jdbc:'))
query = [(k,v) for k,v in urllib.parse.parse_qsl(parsed.query)
if k.lower() not in ('connecttimeout', 'sockettimeout')]
return 'jdbc:' + urllib.parse.urlunsplit(parsed._replace(query=urllib.parse.urlencode(query)))
def launch(name, command, env):
private_directory()
ledger = RUNTIME / (name + '.json')
@@ -114,6 +125,15 @@ def start(config, env, backend_port, redis_port):
available(backend_port)
available(redis_port)
private_directory()
built_jar = ROOT / 'RuoYi-Vue/ruoyi-admin/target/ruoyi-admin.jar'
if not built_jar.is_file():
raise ValueError('Build backend jar first')
digest = hashlib.sha256(built_jar.read_bytes()).hexdigest()
runtime_jar = RUNTIME / ('ruoyi-admin-' + digest[:16] + '.jar')
if not runtime_jar.exists():
shutil.copyfile(built_jar, runtime_jar)
if hashlib.sha256(runtime_jar.read_bytes()).hexdigest() != digest:
raise ValueError('Runtime artifact hash mismatch')
secret_path = RUNTIME / 'runtime-secrets.env'
if not secret_path.exists():
with open(secret_path, 'x', opener=lambda p, f: os.open(p, f, 0o600)) as f:
@@ -125,17 +145,23 @@ def start(config, env, backend_port, redis_port):
if not config.get(key):
raise ValueError('Missing required protected identity configuration')
env[key] = config[key]
env.update(SPRING_DATASOURCE_DRUID_MASTER_URL=env['QA_DB_URL'],
runtime_url = druid_url(env['QA_DB_URL'])
env.update(SPRING_DATASOURCE_DRUID_MASTER_URL=runtime_url,
SPRING_DATASOURCE_DRUID_MASTER_USERNAME='fusion_qa_app',
SPRING_DATASOURCE_DRUID_MASTER_PASSWORD=env['QA_DB_PASSWORD'],
SPRING_DATA_REDIS_PASSWORD=env['QA_REDIS_PASSWORD'])
redis_config = RUNTIME / 'redis.conf'
with open(redis_config, 'w', opener=lambda p, f: os.open(p, f, 0o600)) as f:
f.write('bind 127.0.0.1\nprotected-mode yes\nport ' + str(redis_port) + '\nsave ""\nappendonly no\nrequirepass ' + env['QA_REDIS_PASSWORD'] + '\n')
command = ['java', '-jar', str(ROOT / 'RuoYi-Vue/ruoyi-admin/target/ruoyi-admin.jar'),
command = ['java', '-jar', str(runtime_jar),
'--spring.profiles.active=druid',
'--spring.datasource.druid.master.url=' + env['QA_DB_URL'],
'--spring.datasource.druid.master.url=' + runtime_url,
'--spring.datasource.druid.master.username=fusion_qa_app',
'--spring.datasource.druid.master.connect-timeout=30000',
'--spring.datasource.druid.master.socket-timeout=60000',
'--spring.datasource.druid.master.initial-size=2',
'--spring.datasource.druid.master.min-idle=2',
'--spring.datasource.druid.master.max-active=8',
'--server.address=127.0.0.1', '--server.port=' + str(backend_port),
'--spring.data.redis.host=127.0.0.1', '--spring.data.redis.port=' + str(redis_port),
'--spring.data.redis.database=0', '--spring.quartz.auto-startup=false',
@@ -153,6 +179,16 @@ def start(config, env, backend_port, redis_port):
launch('backend', command, env)
def wait_for_exit(pid):
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
state = subprocess.run(['ps', '-p', str(pid), '-o', 'stat='], text=True, capture_output=True).stdout.strip()
if not state or state.startswith('Z'):
return
time.sleep(0.1)
raise ValueError('Task-owned process did not stop; ledger retained, no forced kill')
def stop():
private_directory()
for name in ['frontend', 'backend', 'redis']:
@@ -174,6 +210,7 @@ def stop():
if not identity or ('n' + str(ROOT)) not in cwd.splitlines() or (item.get('started') and item['started'] != started):
raise ValueError('PID identity mismatch; refusing to signal ' + name)
os.kill(item['pid'], signal.SIGTERM)
wait_for_exit(item['pid'])
path.unlink()
print('Stopped task-owned ' + name)
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Run only on the authorized host. Isolated rehearsal first; source phase is separately gated."""
import argparse
from collections import Counter
import hashlib
import json
from pathlib import Path
import re
import select
import subprocess
ROOT = Path('/opt/railwaysystem-qa-restore-round5v4')
RESTORE = 'railwaysystem-qa-restore-round5v4'
RESTORE_DB = 'railwaysystem_fusion_restore_round5v4'
RECOVERY_DB = 'railwaysystem_fusion_recovery_round5v4'
SOURCE_DB = 'railwaysystem_fusion_qa_20261013'
BIN = '/home/kingbase/install/kingbase/bin/'
MIGRATION_SHA = '90648b6ac67470f9a14f0795c4479dfb4811d4907e41a13ee5eeada16582b87f'
DUMP = Path('/opt/railway-fusion-qa-backup-20261014.YLjmJ0/fusion-qa-pre005-v2.dump')
DUMP_SHA = 'caa62bc985bd433b5d428b36e34d684b557c82576b69e5e5d9d348edfdf316b4'
RESULTS = {}
def argv(container, database):
assert (container == 'kingbase' and database == SOURCE_DB) or (container == RESTORE and database in [RESTORE_DB,RECOVERY_DB,'kingbase'])
role = 'kingbase' if container == 'kingbase' else 'round5v4_admin'
return ['docker','exec','-i',container,BIN+'ksql','-X','-w','-p','54321','-U',role,'-d',database,'-Atq']
def sql(container,database,body,label,expected=None):
result = subprocess.run(argv(container,database),input='\\set ON_ERROR_STOP on\n\\set VERBOSITY sqlstate\n'+body+'\n',text=True,capture_output=True,timeout=90)
(ROOT/(label+'.stdout')).write_text(result.stdout)
(ROOT/(label+'.stderr')).write_text(result.stderr)
if expected:
assert result.returncode != 0 and expected in result.stderr,(label,result.returncode,result.stderr[:100])
else:
assert result.returncode == 0,(label,result.returncode,result.stderr[:100])
return result.stdout.strip()
def manifest(container,database,label):
raw=sql(container,database,(ROOT/'manifest.sql').read_text(),label)
lines=raw.splitlines()
assert sum(l.startswith('TABLE|') for l in lines)==63
assert sum(l.startswith('DATA:') for l in lines)==63
return lines
def canonical(lines):
out=[]
for line in lines:
if line.startswith(('IDENTITY|','OWNER|')):continue
if line.startswith('CONSTRAINT|public|train_external_operation|chk_external_operation_status|'):
# Only this actual native dump/reparse cast difference is admitted, after SQL truth-table verification.
states=re.findall(r"'([^']*)'::varchar",line)
assert states==['CREATED','DISPATCHING','REQUESTED','ACCEPTED','PENDING','RETRY_WAIT','AMBIGUOUS','SUCCESS','FAILED','TIMEOUT']
line='CONSTRAINT_EQUIVALENT_STATUS_SET|'+','.join(states)
out.append(line)
return out
def verify_constraint_equivalence(source,restored):
prefix='CONSTRAINT|public|train_external_operation|chk_external_operation_status|'
a=next(l for l in source if l.startswith(prefix)).split('|',5)[5]
b=next(l for l in restored if l.startswith(prefix)).split('|',5)[5]
assert a.startswith('CHECK (') and b.startswith('CHECK (')
values=re.findall(r"'([^']*)'::varchar",a)+['created','INVALID','',' ']
rows=','.join("('%s'::varchar)"%v.replace("'","''") for v in values)+',(NULL::varchar)'
result=sql(RESTORE,RESTORE_DB,'BEGIN READ ONLY; SELECT count(*) FROM (VALUES '+rows+') AS cases(operation_status) WHERE ('+a[7:-1]+') IS DISTINCT FROM ('+b[7:-1]+'); ROLLBACK;','constraint-equivalence')
assert result=='0',result
RESULTS['constraint_equivalence_cases']=len(values)+1
def migration():
p=ROOT/'005.sql';raw=p.read_bytes();assert hashlib.sha256(raw).hexdigest()==MIGRATION_SHA
statements=[s.strip() for s in re.sub(r'--[^\n]*','',raw.decode()).split(';') if s.strip()]
assert len(statements)==20
assert all(re.match(r'^(ALTER TABLE iotlocks\.(lock_auth|lock_command) ADD COLUMN IF NOT EXISTS |CREATE (UNIQUE )?INDEX IF NOT EXISTS \w+ ON iotlocks\.(lock_auth|lock_command)\()',s) for s in statements)
return raw.decode()
def begin():
return "BEGIN; SET LOCAL lock_timeout='3s'; SET LOCAL statement_timeout='30s';\n"
def validate_post(container,database,label):
result=sql(container,database,"""BEGIN READ ONLY;
SELECT count(*) FROM information_schema.columns WHERE table_schema='iotlocks' AND
((table_name='lock_auth' AND column_name IN ('execution_mode','environment_id','intent_key','intent_version','execution_state','intent_action','intent_at')) OR
(table_name='lock_command' AND column_name IN ('execution_mode','environment_id','intent_key','intent_version','execution_state','intent_action','intent_at','device_sn','attempt_started_at')));
SELECT count(*) FROM pg_indexes WHERE schemaname='iotlocks' AND indexname IN ('uq_round5s_command_intent','uq_round5s_auth_intent','ix_round5s_command_recovery','ix_round5s_auth_recovery');
SELECT count(*) FROM iotlocks.lock_auth WHERE execution_mode<>'LEGACY' OR intent_version<>0 OR execution_state<>'QUARANTINED' OR intent_key IS NOT NULL OR environment_id IS NOT NULL OR intent_action IS NOT NULL;
SELECT count(*) FROM iotlocks.lock_command WHERE execution_mode<>'LEGACY' OR intent_version<>0 OR execution_state<>'QUARANTINED' OR intent_key IS NOT NULL OR environment_id IS NOT NULL OR intent_action IS NOT NULL OR attempt_started_at IS NOT NULL OR device_sn IS NOT NULL;
SELECT count(*) FROM iotlocks.lock_auth; SELECT count(*) FROM iotlocks.lock_command; ROLLBACK;""",label)
assert result.splitlines()==['16','4','0','0','10','15'],result
def isolated():
assert hashlib.sha256(DUMP.read_bytes()).hexdigest()==DUMP_SHA
before=manifest(RESTORE,RESTORE_DB,'restored-baseline')
source=manifest('kingbase',SOURCE_DB,'source-baseline')
verify_constraint_equivalence(source,before)
assert canonical(source)==canonical(before),'Source and restore contents differ'
assert all(l.split('|')[-1]=='round5v4_admin' for l in before if l.startswith('OWNER|'))
RESULTS['native_restore_integrity']='PASS: 63 tables/data fingerprints, schema/columns/indexes/constraints/triggers/sequences/extensions'
body=migration()
sql(RESTORE,RESTORE_DB,begin()+body+'\nSELECT round5v4_deliberate_missing_function();','rollback-error','42883')
assert canonical(manifest(RESTORE,RESTORE_DB,'after-error'))==canonical(before)
RESULTS['ddl_error_transaction_rollback']='PASS'
# EOF disconnects an uncommitted migration connection; native server must roll back.
sql(RESTORE,RESTORE_DB,begin()+body,'rollback-disconnect')
assert canonical(manifest(RESTORE,RESTORE_DB,'after-disconnect'))==canonical(before)
RESULTS['ddl_client_disconnect_rollback']='PASS'
holder=subprocess.Popen(argv(RESTORE,RESTORE_DB),stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
try:
holder.stdin.write("BEGIN; LOCK TABLE iotlocks.lock_auth IN ACCESS SHARE MODE; SELECT 'HOLDER_READY';\n");holder.stdin.flush()
assert select.select([holder.stdout],[],[],10)[0] and holder.stdout.readline().strip()=='HOLDER_READY'
sql(RESTORE,RESTORE_DB,"BEGIN; SET LOCAL lock_timeout='500ms'; SET LOCAL statement_timeout='3s';\n"+body,'lock-timeout','55P03')
finally:
holder.stdin.write('ROLLBACK;\n\\q\n');holder.stdin.flush();holder.wait(timeout=10)
assert canonical(manifest(RESTORE,RESTORE_DB,'after-lock-timeout'))==canonical(before)
RESULTS['ddl_lock_timeout_rollback']='PASS'
sql(RESTORE,RESTORE_DB,begin()+body+'\nCOMMIT;','migration-commit')
validate_post(RESTORE,RESTORE_DB,'post-migration-check')
post=manifest(RESTORE,RESTORE_DB,'isolated-post')
assert [l for l in before if l.startswith('LEGACY_DATA:')]==[l for l in post if l.startswith('LEGACY_DATA:')]
sql(RESTORE,RESTORE_DB,begin()+body+'\nCOMMIT;','migration-repeat')
assert canonical(manifest(RESTORE,RESTORE_DB,'after-repeat'))==canonical(post)
RESULTS['migration_commit_repeat_legacy_isolation']='PASS'
sql(RESTORE,RESTORE_DB,"""BEGIN;
INSERT INTO iotlocks.lock_command(command_id,command_no,command_type,target_type,intent_key) VALUES(-500000000001,'R5V4_UNIQUE_A','0012','1','round5v4-unique-intent');
INSERT INTO iotlocks.lock_command(command_id,command_no,command_type,target_type,intent_key) VALUES(-500000000002,'R5V4_UNIQUE_B','0012','1','round5v4-unique-intent');
COMMIT;""",'unique-intent-rejection','23505')
assert canonical(manifest(RESTORE,RESTORE_DB,'after-unique-rollback'))==canonical(post)
RESULTS['unique_intent_and_rollback']='PASS'
# Fresh target only: never DROP/overwrite either the source QA or the first restored database.
sql(RESTORE,'kingbase','CREATE DATABASE '+RECOVERY_DB+' OWNER round5v4_admin TEMPLATE template0;', 'create-recovery-db')
with DUMP.open('rb') as stream:
r=subprocess.run(['docker','exec','-i',RESTORE,BIN+'sys_restore','-w','-p','54321','-U','round5v4_admin','-d',RECOVERY_DB,'-O','-x','--no-tablespaces','-1','-e'],stdin=stream,capture_output=True,timeout=90)
(ROOT/'recovery-restore.stderr').write_bytes(r.stderr)
assert r.returncode==0 and not r.stderr
assert canonical(manifest(RESTORE,RECOVERY_DB,'fresh-recovered'))==canonical(before)
RESULTS['native_backup_failure_recovery']='PASS'
assert canonical(manifest('kingbase',SOURCE_DB,'source-still-unmodified'))==canonical(source)
RESULTS['source_unchanged_during_rehearsal']='PASS'
(ROOT/'isolated-results.json').write_text(json.dumps(RESULTS,indent=2))
print(json.dumps(RESULTS,indent=2))
def source_migrate():
proof=json.loads((ROOT/'isolated-results.json').read_text())
required=['native_restore_integrity','ddl_error_transaction_rollback','ddl_client_disconnect_rollback',
'ddl_lock_timeout_rollback','migration_commit_repeat_legacy_isolation',
'unique_intent_and_rollback','native_backup_failure_recovery','source_unchanged_during_rehearsal']
assert all(proof.get(k,'').startswith('PASS') for k in required)
assert hashlib.sha256(DUMP.read_bytes()).hexdigest()==DUMP_SHA
baseline=(ROOT/'source-baseline.stdout').read_text().strip().splitlines()
current=manifest('kingbase',SOURCE_DB,'source-immediately-before')
assert canonical(current)==canonical(baseline),'Source changed after the proven backup'
sessions=sql('kingbase',SOURCE_DB,'BEGIN READ ONLY; SELECT count(*) FROM pg_stat_activity WHERE datname=current_database() AND pid<>pg_backend_pid(); ROLLBACK;','source-other-sessions')
assert sessions=='0','Other source QA sessions exist; coordinate writers first'
# Recheck affected data under the actual migration locks, not only before acquiring them.
guards=[]
for table,extra in [('lock_auth',[]),('lock_command',['device_sn','attempt_started_at'])]:
prefix='LEGACY_DATA:'+table+'|'
expected=next(l for l in current if l.startswith(prefix)).split('|')[2]
excluded=['execution_mode','environment_id','intent_key','intent_version','execution_state','intent_action','intent_at']+extra
array='ARRAY['+','.join("'%s'"%s for s in excluded)+']'
key='auth_id' if table=='lock_auth' else 'command_id'
expression="(SELECT md5(coalesce(string_agg((to_jsonb(t)-"+array+")::text,'' ORDER BY "+key+"),'')) FROM iotlocks."+table+" t)"
guards.append("IF "+expression+" IS DISTINCT FROM '"+expected+"' THEN RAISE EXCEPTION 'QA data changed; migration refused'; END IF;")
guard='DO $guard$ BEGIN '+''.join(guards)+' END $guard$;\n'
body=begin()+"LOCK TABLE iotlocks.lock_auth,iotlocks.lock_command IN ACCESS EXCLUSIVE MODE;\n"+guard+migration()+'\nCOMMIT;'
sql('kingbase',SOURCE_DB,body,'source-migration-commit')
validate_post('kingbase',SOURCE_DB,'source-post-migration-check')
post=manifest('kingbase',SOURCE_DB,'source-after-migration')
isolated_post=(ROOT/'isolated-post.stdout').read_text().strip().splitlines()
assert canonical(post)==canonical(isolated_post),'Unexpected source schema/data difference'
result={'source_qa_migration':'PASS','schema_and_data_match_rehearsal':'PASS',
'legacy_auth_count':10,'legacy_command_count':15,'added_columns':16,'added_indexes':4,
'privileges_expanded':False,'other_databases_modified':False}
(ROOT/'source-migration-results.json').write_text(json.dumps(result,indent=2));print(json.dumps(result,indent=2))
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--source-only',action='store_true');args=parser.parse_args()
source_migrate() if args.source_only else isolated()
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Bounded watchdog for ONLY the separately authorized Round5V4 restore container."""
import argparse
import json
import os
from pathlib import Path
import shutil
import subprocess
import time
NAME = 'railwaysystem-qa-restore-round5v4'
IMAGE = 'sha256:540fa4d0c8f365c8d363a801404e93f8401c7bc5035ecd7b621f0dba862f2515'
ROOT = Path('/opt/railwaysystem-qa-restore-round5v4')
LIMIT = 4 * 1024 ** 3 # stop below the approved 5GiB budget; bind has NO hard quota
RESERVE = 100 * 1024 ** 3
def command(*args):
return subprocess.check_output(args, text=True, timeout=20).strip()
def inspect(container_id):
item = json.loads(command('docker', 'inspect', container_id))[0]
if item['Id'] != container_id or item['Name'] != '/' + NAME or item['Image'] != IMAGE:
raise RuntimeError('Container identity mismatch; no unrelated process will be signaled')
if item['Config'].get('Labels', {}).get('railway.task') != 'round5v4':
raise RuntimeError('Missing task ownership label')
mounts = [m for m in item['Mounts'] if m['Destination'] == '/home/kingbase/userdata']
if len(mounts) != 1 or Path(mounts[0]['Source']).resolve() != ROOT / 'data':
raise RuntimeError('Unexpected data mount')
return item
def used_bytes(path):
return int(command('du', '-sk', str(path)).split()[0]) * 1024
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--once', action='store_true')
args = parser.parse_args()
if ROOT.is_symlink() or ROOT.resolve() != ROOT or (ROOT / 'data').is_symlink():
raise RuntimeError('Task paths must not be symlinks')
container_id = (ROOT / 'container.id').read_text().strip()
ever_running = False
while True:
item = inspect(container_id)
running = item['State']['Running']
ever_running = ever_running or running
upper = Path(item['GraphDriver']['Data']['UpperDir']).resolve()
if not str(upper).startswith('/var/lib/docker/overlay2/') or upper.name != 'diff':
raise RuntimeError('Unexpected writable-layer location')
used = used_bytes(ROOT) + used_bytes(upper)
log_path = item.get('LogPath')
used += Path(log_path).stat().st_size if log_path and Path(log_path).exists() else 0
free = shutil.disk_usage(ROOT).free
print(json.dumps({'time': time.time(), 'running': running, 'used_bytes': used,
'stop_threshold_bytes': LIMIT, 'host_free_bytes': free}), flush=True)
if used >= LIMIT or free < RESERVE:
# Revalidate immutable task ownership immediately before stopping.
inspect(container_id)
subprocess.run(['docker', 'stop', '--time', '15', container_id], check=True, timeout=25,
stdout=subprocess.DEVNULL)
print('STOPPED_TASK_CONTAINER_STORAGE_THRESHOLD', flush=True)
return
if args.once or (ever_running and not running):
return
time.sleep(2)
if __name__ == '__main__':
main()
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Opt-in synthetic writes to the migrated, named Kingbase QA database only. No DDL or network devices."""
import argparse
import os
from pathlib import Path
import subprocess
import qa
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--approve-synthetic-qa-writes', action='store_true', required=True)
parser.add_argument('--config', type=Path, default=Path.home()/'.railwaysystem-qa.env')
args = parser.parse_args()
env = qa.environment(qa.load_config(args.config))
if qa.inspect(env, ['--require-migrated']):
raise SystemExit('QA schema preflight failed')
qa.private_directory()
directory = qa.RUNTIME/'round5v4'
directory.mkdir(mode=0o700, exist_ok=True)
env.update(ROUND5V4_KINGBASE_TESTS='1', ROUND5V4_LOG_DIR=str(directory))
command = ['mvn', '-o', '-q', '-f', 'RuoYi-Vue/pom.xml', '-pl', 'ruoyi-train', '-am',
'-Dtest=Round5v4KingbaseIntegrationTest', '-Dsurefire.failIfNoSpecifiedTests=false', 'test']
with open(directory/'kingbase-tests.log','w',opener=lambda p,f:os.open(p,f,0o600)) as log:
result = subprocess.run(command, cwd=qa.ROOT, env=env, stdout=log, stderr=subprocess.STDOUT)
print('KINGBASE_TEST_EXIT='+str(result.returncode))
print('PRIVATE_LOG='+str(directory/'kingbase-tests.log'))
return result.returncode
if __name__=='__main__':
raise SystemExit(main())
+5 -1
View File
@@ -34,6 +34,10 @@ class QaToolsTest(unittest.TestCase):
with self.assertRaises(ValueError):
qa.environment({})
def test_druid_timeout_units_cannot_be_overridden_by_jdbc_url_seconds(self):
url = 'jdbc:kingbase8://192.168.139.218:4321/' + qa.DATABASE + '?connectTimeout=5&socketTimeout=15&characterEncoding=utf8'
self.assertEqual('jdbc:kingbase8://192.168.139.218:4321/' + qa.DATABASE + '?characterEncoding=utf8', qa.druid_url(url))
def test_unsafe_inherited_overrides_removed(self):
with patch.dict(os.environ, {'SPRING_APPLICATION_JSON': '{"danger":true}', 'IOT_MQTT_ENABLED': 'true', 'JAVA_TOOL_OPTIONS': '-Dunsafe=true'}):
env = qa.environment({'FUSION_QA_APP_PASSWORD': 'synthetic'})
@@ -91,7 +95,7 @@ class QaToolsTest(unittest.TestCase):
def test_stop_accepts_python_wrapper_only_with_task_identity(self):
with tempfile.TemporaryDirectory() as directory, patch.object(qa, 'RUNTIME', Path(directory)), \
patch.object(qa.subprocess, 'run') as run, patch.object(qa.os, 'kill') as kill:
patch.object(qa.subprocess, 'run') as run, patch.object(qa.os, 'kill') as kill, patch.object(qa, 'wait_for_exit'):
script = str(qa.ROOT / 'scripts/qa/serve_frontend.py')
command = ['/usr/local/bin/python3', script, '--port', '18088']
(Path(directory)/'frontend.json').write_text(json.dumps({'pid':123,'command':command,'started':'same-birth-time'}))