test(qa): verify native Kingbase restore migration and fusion workflows
This commit is contained in:
1 parent
ba5dc7a6af
commit
6e73d34561
23 files changed
+1103
-31
No files matched your search
@@ -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();}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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]};
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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'}))
|
||||
|
||||
Reference in new issue
Block a user