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

+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'}))