// Gray evidence uses the registered production SSH target, never the TEST agent. import { assertReadOnlySql, buildReceivingEvidenceSql, receivingEvidenceFromRows, buildCompletionEvidenceSql, completionEvidenceFromRows } from './fba-core-regression-evidence.mjs' import { buildFleetInventorySql, fleetInventoryFromRows } from './fleet-inventory-evidence.mjs' const transactionStart = ['SET NAMES utf8mb4 COLLATE utf8mb4_bin;', 'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;', 'START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;'] export const GRAY_EVIDENCE_ENV_KEYS = ['JENKINS_URL', 'JENKINS_USER', 'JENKINS_API_TOKEN'] export function validateGrayEvidenceConfig(env) { if (GRAY_EVIDENCE_ENV_KEYS.some(key => typeof env[key] !== 'string' || !env[key].trim())) throw Error('Missing gray evidence configuration') const url = new URL(env.JENKINS_URL) if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw Error('Invalid gray evidence configuration') } const encodedExpression = value => '[' + Buffer.from(value).toString('base64').match(/.{1,4000}/g) .map(part => `'${part}'`).join(',') + "].join('')" export function buildGrayEvidenceRemoteScript(service, sql) { assertReadOnlySql(sql) if (!['order', 'lastleg'].includes(service)) throw Error('Invalid gray evidence target') const prefix = service.toUpperCase(), container = `firstleg-production-${service}` const schema = `baiyue_${service}` const composeService = service === 'lastleg' ? 'lastleg-service' : service const python = `import base64,json,os,re,subprocess,urllib.parse stage='CONFIG' try: expected=json.loads(base64.b64decode(expected_encoded)) stage='CONTAINER_IDENTITY' c=json.loads(subprocess.check_output(['docker','inspect','${container}'],text=True,stderr=subprocess.DEVNULL,timeout=10))[0] labels=c.get('Config',{}).get('Labels',{}) if c.get('Name')!='/${container}' or labels.get('com.docker.compose.project')!='firstleg-production' or labels.get('com.docker.compose.service')!='${composeService}' or c.get('State',{}).get('Running') is not True: raise RuntimeError('target') e=dict(v.split('=',1) for v in c['Config']['Env'] if '=' in v) stage='PROFILE' if e.get('DEPLOY_ENV')!='production' or e.get('SPRING_PROFILES_ACTIVE')!='production': raise RuntimeError('profile') stage='DATABASE_SOURCE' for key in ['${prefix}_DB_URL','${prefix}_DB_USERNAME','${prefix}_DB_PASSWORD']: if not expected.get(key) or e.get(key)!=expected[key]: raise RuntimeError('database source') stage='DATABASE_IDENTITY' u=urllib.parse.urlsplit(e['${prefix}_DB_URL'].removeprefix('jdbc:')) if u.scheme!='mysql' or not u.hostname or u.path!='/'+ '${schema}' or u.username or u.password: raise RuntimeError('database identity') childEnv=dict(os.environ,MYSQL_PWD=e['${prefix}_DB_PASSWORD']) host='127.0.0.1' if u.hostname=='host.docker.internal' else u.hostname sql=base64.b64decode('${Buffer.from(sql).toString('base64')}').decode('utf-8') stage='MYSQL_EXECUTION' p=subprocess.run(['mysql','--connect-timeout=8','--host='+host,'--port='+str(u.port or 3306),'--user='+e['${prefix}_DB_USERNAME'],'--batch','--raw','--skip-column-names','${schema}'],input=sql,capture_output=True,text=True,env=childEnv,timeout=35) if p.returncode: code=re.search(r'ERROR ([0-9]{3,5})',p.stderr) print('GRAY_EVIDENCE_JSON='+json.dumps({'ok':False,'error':'MYSQL_'+(code.group(1) if code else 'UNKNOWN')})) else: print('GRAY_EVIDENCE_JSON='+json.dumps({'ok':True,'rows':[json.loads(line) for line in p.stdout.splitlines() if line.strip()]})) except Exception: print('GRAY_EVIDENCE_JSON='+json.dumps({'ok':False,'error':'GRAY_'+stage+'_FAILED'})) ` return `def cs=com.cloudbees.plugins.credentials.CredentialsProvider.lookupCredentials(com.cloudbees.plugins.credentials.common.StandardCredentials.class,jenkins.model.Jenkins.instance,null,null) def key=cs.find{it.id=='firstleg-production-ssh'} def hostCredential=cs.find{it.id=='firstleg-production-host'} def known=cs.find{it.id=='firstleg-production-known-hosts'} def environment=cs.find{it.id=='firstleg-production-env'} if(key==null || hostCredential==null || known==null || environment==null) throw new RuntimeException('Gray evidence credentials unavailable') def host=hostCredential.secret.plainText if(!(host==~/[A-Za-z0-9][A-Za-z0-9.:-]*/) || !(key.username==~/[A-Za-z_][A-Za-z0-9_.-]*/)) throw new RuntimeException('Invalid gray SSH target') def wanted=['${prefix}_DB_URL','${prefix}_DB_USERNAME','${prefix}_DB_PASSWORD'] def expected=[:] environment.content.getText('UTF-8').readLines().each{line -> if(line.contains('=')){def pair=line.split('=',2); if(wanted.contains(pair[0]))expected[pair[0]]=pair[1]} } if(expected.size()!=wanted.size()) throw new RuntimeException('Gray evidence database configuration unavailable') def script="expected_encoded='"+java.util.Base64.encoder.encodeToString(groovy.json.JsonOutput.toJson(expected).getBytes('UTF-8'))+"'\\n"+new String(java.util.Base64.decoder.decode(${encodedExpression(python)}),'UTF-8') def dir=java.nio.file.Files.createTempDirectory('gray-evidence-ssh-').toFile() try { def k=new File(dir,'key'); k.text=key.privateKeys[0]; k.setReadable(false,false); k.setReadable(true,true); k.setWritable(false,false); k.setWritable(true,true) def h=new File(dir,'known_hosts'); h.bytes=known.content.bytes def p=new ProcessBuilder(['ssh','-i',k.path,'-o','BatchMode=yes','-o','ConnectTimeout=10','-o','ServerAliveInterval=5','-o','ServerAliveCountMax=2','-o','StrictHostKeyChecking=yes','-o','UserKnownHostsFile='+h.path,key.username+'@'+host,'python3','-']).redirectErrorStream(true).start() p.outputStream.write(script.getBytes('UTF-8')); p.outputStream.close() def output=new StringBuffer(); def reader=p.consumeProcessOutputStream(output) if(!p.waitFor(50,java.util.concurrent.TimeUnit.SECONDS)){p.destroyForcibly(); throw new RuntimeException('Gray evidence SSH timeout')} if(p.exitValue()!=0) throw new RuntimeException('Gray evidence SSH failed') reader.join(1000) if(reader.isAlive()) throw new RuntimeException('Gray evidence SSH output incomplete') print(output.toString()) } finally {dir.listFiles().each{it.delete()};dir.delete()}` } export function createGrayEvidenceReaders(env, fetcher = globalThis.fetch) { validateGrayEvidenceConfig(env) async function execute(service, sql) { const script = buildGrayEvidenceRemoteScript(service, sql) let response try { response = await fetcher(new URL('scriptText', env.JENKINS_URL.replace(/\/?$/, '/')), { method: 'POST', headers: { Authorization: 'Basic ' + Buffer.from(env.JENKINS_USER + ':' + env.JENKINS_API_TOKEN).toString('base64'), 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ script }), signal: AbortSignal.timeout(55000), }) } catch { throw Error('Gray evidence transport failed') } if (!response.ok) throw Error('Gray evidence HTTP ' + response.status) let raw try { raw = await response.text() } catch { throw Error('Gray evidence response failed') } const lines = raw.split(/\r?\n/).filter(line => line.startsWith('GRAY_EVIDENCE_JSON=')) if (lines.length !== 1) throw Error('Gray evidence JSON missing or ambiguous') let result try { result = JSON.parse(lines[0].slice('GRAY_EVIDENCE_JSON='.length)) } catch { throw Error('Gray evidence JSON invalid') } if (result?.ok !== true || !Array.isArray(result.rows)) throw Error(/^(MYSQL_[0-9]{3,5}|GRAY_(CONFIG|CONTAINER_IDENTITY|PROFILE|DATABASE_SOURCE|DATABASE_IDENTITY|MYSQL_EXECUTION)_FAILED)$/.test(result?.error) ? result.error : 'Gray evidence query failed') return result.rows } return { readReceiving: async scope => receivingEvidenceFromRows(scope, await execute('lastleg', buildReceivingEvidenceSql(scope))), readCompletion: async scope => completionEvidenceFromRows(scope, await execute('order', buildCompletionEvidenceSql(scope))), readFleetInventory: async scope => fleetInventoryFromRows(scope, await execute('lastleg', buildFleetInventorySql(scope))), preflight: async () => { for (const service of ['order', 'lastleg']) { const rows = await execute(service, [...transactionStart, "SELECT JSON_OBJECT('grayProbe',1);", 'COMMIT;'].join('\n')) if (rows.length !== 1 || rows[0].grayProbe !== 1) throw Error('Gray evidence preflight failed') } }, } }