119 lines
8.2 KiB
JavaScript
119 lines
8.2 KiB
JavaScript
// 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')
|
|
}
|
|
},
|
|
}
|
|
}
|