Files

119 lines
8.2 KiB
JavaScript
Raw Permalink Normal View History

2026-10-08 17:07:57 +08:00
// 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')
}
},
}
}