test(order): add isolated dependency failure and recovery verification
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
# 后端脚本管理与全量 HTTP 验收索引
|
||||
|
||||
头程订单提交使用首次原始详情的 `expectedVersion` 与原 `Idempotency-Key`。生产单票的 `SUBMIT_INTENT` 必须先持久化 `submitCommand` 再发送,显式续跑不得使用当前版本替换原命令,缺少原命令的提交意图及后续检查点停止并等待人工核对。源 Excel 造数同样在提交前保存订单条目的 `submitCommand`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。
|
||||
|
||||
`order-submit-dependency-fault-executor.mjs` 是显式 TEST 下单依赖故障入口,内部使用 `order-submit-dependency-fault-proxy.py`、`order-submit-dependency-fault-runtime.py`。`plan --plan <文件>` 只读校验;`preflight --plan <文件> --routing <文件> --confirm-test-runtime yes` 核对测试 Order 镜像、实际依赖和 Nacos 配置;`run` 另需 `--expected-image <镜像ID>`、全新 `--report <文件>`。凭据仅从明确指定的 TEST 与 Jenkins 环境文件读取,路由文件不得包含凭据。必须事先准备本轮两个独立合法草稿、原始版本与幂等键及完整快照;仅对本轮客户的 IAM 资格和 Finance 钱包 GET 注入短时故障,验证两端提交失败后事实不变。运行会临时重建同镜像 Order 容器并在恢复后真实提交草稿,因此不得与部署或其他共享环境造数并行;不写业务 SQL。结束先恢复直通、原镜像配置、健康与依赖读取,再停止代理;失败保留报告和受限恢复文件,不自动重跑或删除数据。结果未知时使用 `status`、`reconcile` 核对原操作,必要时显式 `restore --plan <文件> --report <新文件> --confirm-test-runtime yes`,不得因观察超时重复创建容器。离线合同为对应 proxy/executor `.test.mjs`,离线通过不代表真实故障注入已验收。
|
||||
`event-delivery-fault-proxy.py` 是 TEST 环境定向事件 ACK 丢失工具,不创建或重放事件。运行者显式传入 `--upstream` 完整消费者地址、`--event-type`,并只选 `--event-id`、`--loading-plan-id` 或 `--container-booking-id`;柜次选择精确匹配 payload.sourceContainerBookingId,用于海外卸柜回写,不接受混合选择条件。`EVENT_FAULT_CONTROL_TOKEN` 从外部环境注入,保护固定本机监听的控制端口。默认 PASS;经鉴权切为 FAULT 后,仅在选定事件已真实转发且收到匹配的 APPLIED/DUPLICATE ACK 时向发送方返回 503,其他事件直通。同一选择范围内锁定首个事件后不改选其他事件 ID,原 ID 改摘要或载荷直接拒绝。日志只保留事件标识、摘要和结果,不保留正文或凭据。它会影响实际投递,仅限明确授权的测试环境;生产者配置、重试、DEAD 原事件人工恢复及最终恢复直连由运行者负责,工具退出不自动恢复服务配置。`--log` 可显式指定脱敏证据文件,失败保留原事件与日志,禁止改摘要或借其他事件重跑。`event-delivery-fault-proxy.test.mjs` 使用本机替身服务验证行为,不访问共享环境;工具离线通过不能替代真实重试验收。
|
||||
|
||||
`admin-test-env-acceptance.mjs` 与 `client-test-env-acceptance.mjs` 仅接受统一 TEST Gateway 和 Auth 会话;移除环境文件中的 `ADMIN_OVERSEAS_GATEWAY`,不再传 `--overseas-gateway` 或非 test 环境。平台、客户、PDA 身份仍分别登录并验证权限及客户隔离。海外资源接口使用 `/api/platform/v1/overseas-warehouse-resources/**`,仓库主档仍归 Warehouse。跨服务模块的重复 GET 不证明事件重放;真实重试/幂等证据缺失会报告部署阻断,详见模块 README 和下方事件人工重试入口。离线合同不会执行真实 HTTP;环境执行仍需本轮写入授权。
|
||||
|
||||
@@ -2,7 +2,54 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { fixtureInboundCode, orderPersistenceFacts, requestedOrderDestinationType, freezeOrderCommand, verifyPlatformDraftEdit, verifyPlatformSubmitCancel } from './orders.mjs'
|
||||
import { fixtureInboundCode, orderPersistenceFacts, requestedOrderDestinationType, freezeOrderCommand, verifyPlatformDraftEdit, verifyPlatformSubmitCancel,
|
||||
buildOrderDependencyFaultPlan, assertOrderDependencyFaultObservation } from './orders.mjs'
|
||||
|
||||
function dependencyFixture() {
|
||||
const detail = id => ({order:{id,orderNo:`FL${id}`,tenantId:'20',status:'DRAFT',version:4},
|
||||
cargoes:[{skuNo:'current'}],parties:[],internalNotes:[],statusHistory:[]})
|
||||
return {marker:'CURRENT-RUN',customerId:'20',registeredCustomerId:'20',merchant:detail('101'),platform:detail('102')}
|
||||
}
|
||||
function dependencyObservation(plan, fixture, dependency, role) {
|
||||
const phase=plan.phases.find(row=>row.dependency===dependency),command=plan.commands[role]
|
||||
const snapshot={detail:structuredClone(fixture[role]),task:null,inbounds:[],inboundPageComplete:true}
|
||||
return {request:{method:command.method,path:command.path,body:structuredClone(command.body),headers:{...command.headers,'X-Request-Id':`${dependency}-${role}`}},
|
||||
response:{http:200,body:{code:'S0503',message:phase.expectedMessages[0]}},before:snapshot,after:structuredClone(snapshot),
|
||||
proxyRecords:[{requestId:`${dependency}-${role}`,method:'GET',path:phase.targetPath,matched:true,responseStatus:503,forwarded:false,internalTokenPresent:true}]}
|
||||
}
|
||||
test('dependency plan binds two new-customer drafts and only named Order upstream properties; it performs no runtime actions',()=>{
|
||||
const fixture=dependencyFixture(),plan=buildOrderDependencyFaultPlan(fixture)
|
||||
assert.equal(plan.runtimeChangesExecuted,false)
|
||||
assert.deepEqual(plan.phases.map(row=>row.urlProperty),['baiyue.clients.iam.url','baiyue.clients.finance.url'])
|
||||
assert.equal(plan.commands.merchant.body.expectedVersion,4)
|
||||
fixture.merchant.order.version=9
|
||||
assert.equal(plan.commands.merchant.body.expectedVersion,4)
|
||||
assert.ok(Object.isFrozen(plan.commands.merchant.body))
|
||||
for(const change of [f=>f.customerId='0',f=>f.registeredCustomerId='21',f=>f.platform.order.id='101',f=>f.merchant.order.status='SUBMITTED',f=>delete f.merchant.cargoes]) {
|
||||
const value=dependencyFixture();change(value);assert.throws(()=>buildOrderDependencyFaultPlan(value))
|
||||
}
|
||||
})
|
||||
test('all four dependency-role observations require matching authenticated 503 injection and actual Order S0503 with zero drift',()=>{
|
||||
const fixture=dependencyFixture(),plan=buildOrderDependencyFaultPlan(fixture)
|
||||
for(const dependency of ['IAM','FINANCE'])for(const role of ['merchant','platform']) {
|
||||
const evidence=dependencyObservation(plan,fixture,dependency,role)
|
||||
assert.equal(assertOrderDependencyFaultObservation(plan,dependency,role,evidence).verified,true)
|
||||
const wrapped=structuredClone(evidence)
|
||||
wrapped.response.body.message=plan.phases.find(row=>row.dependency===dependency).expectedMessages[1]
|
||||
assert.equal(assertOrderDependencyFaultObservation(plan,dependency,role,wrapped).verified,true)
|
||||
for(const mutate of [
|
||||
value=>value.response.http=503,value=>value.response.body.code='A0403',value=>value.response.body.message='资格未审核',
|
||||
value=>value.request.body.expectedVersion=5,value=>value.request.headers['Idempotency-Key']='new-key',
|
||||
value=>value.proxyRecords=[],value=>value.proxyRecords[0].internalTokenPresent=false,value=>value.proxyRecords[0].forwarded=true,
|
||||
value=>value.proxyRecords[0].path+='/other',value=>value.proxyRecords[0].requestId='other-request',
|
||||
value=>value.proxyRecords.push({...value.proxyRecords[0],path:'/internal/iam/tenants/21/order-eligibility'}),
|
||||
value=>value.after.detail.order.version++,value=>value.after.detail.statusHistory.push({id:'unexpected'}),
|
||||
value=>value.after.task={orderId:plan.commands[role].orderId,tenantId:'20',id:'unexpected'},
|
||||
value=>value.after.inbounds.push({tenantId:'20',orderNo:plan.commands[role].orderNo,id:'unexpected'}),
|
||||
value=>value.after.inboundPageComplete=false,
|
||||
]) { const changed=structuredClone(evidence);mutate(changed);assert.throws(()=>assertOrderDependencyFaultObservation(plan,dependency,role,changed)) }
|
||||
}
|
||||
})
|
||||
|
||||
function platformLifecycleFixture(fault = '', faultAction = 'submit') {
|
||||
const drafts = new Map(), memo = new Map(), calls = []
|
||||
|
||||
@@ -2,8 +2,6 @@ import { createHash } from 'node:crypto'
|
||||
|
||||
const MODULE = 'orders'
|
||||
const CONTRACT_OPTIONS = { scenario: 'isolated-order-chain', deploymentBlocker: true }
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || ''
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || ''
|
||||
const BOXES = [
|
||||
{ boxType: 'SMALL', lengthCm: 30, widthCm: 20, heightCm: 20 },
|
||||
{ boxType: 'MEDIUM', lengthCm: 40, widthCm: 30, heightCm: 30 },
|
||||
@@ -243,6 +241,69 @@ export function orderPersistenceFacts(detail) {
|
||||
statusHistory: Array.isArray(detail?.statusHistory) ? detail.statusHistory : [],
|
||||
})
|
||||
}
|
||||
// Read-only planning and evidence validation only. These exports never configure services or send requests.
|
||||
export function buildOrderDependencyFaultPlan(fixture) {
|
||||
const customerId = id(fixture?.customerId)
|
||||
if (!/^[1-9]\d*$/.test(customerId) || customerId !== id(fixture?.registeredCustomerId)
|
||||
|| !/^[A-Za-z0-9_-]{1,80}$/.test(fixture?.marker || '')) throw new Error('Dependency fault fixture must belong to this run registered customer')
|
||||
const commands = {}
|
||||
for (const role of ['merchant','platform']) {
|
||||
const detail = fixture[role]
|
||||
if (id(detail?.order?.tenantId) !== customerId || !/^[1-9]\d*$/.test(id(detail?.order?.id))
|
||||
|| detail.order.status !== 'DRAFT' || !detail.order.orderNo
|
||||
|| !['cargoes','parties','internalNotes','statusHistory'].every(field => Array.isArray(detail[field]))) {
|
||||
throw new Error(`Incomplete owned ${role} DRAFT evidence`)
|
||||
}
|
||||
const command = freezeOrderCommand(detail, `dep-${role}-${fixture.marker}`)
|
||||
commands[role] = { method:'POST', path:`/api/${role}/v1/orders/first-leg/${detail.order.id}/submit${role==='platform'?`?tenantId=${customerId}`:''}`,
|
||||
...command, orderId:id(detail.order.id), orderNo:detail.order.orderNo,
|
||||
initialOrderSha256:createHash('sha256').update(orderPersistenceFacts(detail)).digest('hex') }
|
||||
}
|
||||
if (commands.merchant.orderId === commands.platform.orderId) throw new Error('Use two independent drafts for same-key recovery')
|
||||
return { marker:fixture.marker, customerId, runtimeChangesExecuted:false, commands,
|
||||
phases:[
|
||||
{ dependency:'IAM', urlProperty:'baiyue.clients.iam.url', targetMethod:'GET', targetPath:`/internal/iam/tenants/${customerId}/order-eligibility`,
|
||||
faultHttp:503, expectedHttp:200, expectedCode:'S0503', expectedMessages:['IAM eligibility service is unavailable','IAM 下单资格响应无效'] },
|
||||
{ dependency:'FINANCE', urlProperty:'baiyue.clients.finance.url', targetMethod:'GET', targetPath:`/internal/finance/v1/wallet/accounts/${customerId}`,
|
||||
faultHttp:503, expectedHttp:200, expectedCode:'S0503', expectedMessages:['Finance account service is unavailable','Finance account response is invalid'] },
|
||||
],
|
||||
recovery:'Restore original Order image/config/network identity; submit each original frozen command once, then replay it; require one submission history and no duplicate Firstleg facts.' }
|
||||
}
|
||||
|
||||
export function assertOrderDependencyFaultObservation(plan, dependency, role, observation) {
|
||||
const phase = plan?.phases?.find(item => item.dependency === dependency), command = plan?.commands?.[role]
|
||||
const require = (ok, message) => { if (!ok) throw new Error(`Dependency fault evidence: ${message}`) }
|
||||
require(Boolean(phase && command), 'unknown dependency or role')
|
||||
const request = observation?.request, response = observation?.response
|
||||
require(request?.method === command.method && request.path === command.path
|
||||
&& JSON.stringify(request.body) === JSON.stringify(command.body)
|
||||
&& request.headers?.['Idempotency-Key'] === command.headers['Idempotency-Key'], 'original frozen request/version/key changed')
|
||||
const requestId = request.headers?.['X-Request-Id']
|
||||
require(typeof requestId === 'string' && requestId.length > 0, 'missing correlation ID')
|
||||
require(response?.http === phase.expectedHttp && String(response.body?.code) === phase.expectedCode
|
||||
&& phase.expectedMessages.includes(response.body.message), 'must be the real Order dependency failure, not auth/version/business validation')
|
||||
require(Array.isArray(observation.proxyRecords) && observation.proxyRecords.some(row =>
|
||||
row.requestId === requestId && row.method === phase.targetMethod && row.path === phase.targetPath
|
||||
&& row.matched === true && row.responseStatus === phase.faultHttp && row.forwarded === false
|
||||
&& row.internalTokenPresent === true), 'missing exact authenticated upstream fault hit')
|
||||
require(observation.proxyRecords.filter(row => row.matched === true).every(row => row.method === phase.targetMethod
|
||||
&& row.path === phase.targetPath && row.responseStatus === phase.faultHttp && row.forwarded === false), 'fault escaped selected customer/path')
|
||||
for (const label of ['before','after']) {
|
||||
const snapshot = observation[label], detail = snapshot?.detail
|
||||
require(snapshot?.inboundPageComplete === true && Array.isArray(snapshot.inbounds)
|
||||
&& ['cargoes','parties','internalNotes','statusHistory'].every(field => Array.isArray(detail?.[field]))
|
||||
&& id(detail?.order?.id) === command.orderId && id(detail.order.tenantId) === plan.customerId
|
||||
&& detail.order.status === 'DRAFT', `${label} incomplete/foreign order or Firstleg evidence`)
|
||||
require(snapshot.task === null || snapshot.task && id(snapshot.task.orderId) === command.orderId
|
||||
&& id(snapshot.task.tenantId) === plan.customerId, `${label} task source mismatch`)
|
||||
require(snapshot.inbounds.every(row => id(row.tenantId) === plan.customerId && row.orderNo === command.orderNo), `${label} inbound source mismatch`)
|
||||
}
|
||||
require(createHash('sha256').update(orderPersistenceFacts(observation.before.detail)).digest('hex') === command.initialOrderSha256,
|
||||
'baseline differs from prepared draft')
|
||||
require(JSON.stringify(observation.before) === JSON.stringify(observation.after), 'order/version/history/Firstleg facts changed')
|
||||
return { dependency, role, orderId:command.orderId, verified:true, requestId,
|
||||
snapshotSha256:createHash('sha256').update(JSON.stringify(observation.after)).digest('hex') }
|
||||
}
|
||||
export function requestedOrderDestinationType(context) {
|
||||
const value = String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase()
|
||||
return ['FBA', 'OVERSEAS', 'PRIVATE'].includes(value) ? value : 'FBA'
|
||||
@@ -848,11 +909,18 @@ async function waitForInbound(context, tenantId, orderId) {
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
// Resolve per invocation: this module is cached across serial fresh-customer runs.
|
||||
const fixture = context.context?.merchantQualificationFixture
|
||||
const configured = context.options?.merchantUsername || context.options?.merchantPassword
|
||||
const credentials = fixture ? { username: fixture.username, password: fixture.password }
|
||||
: configured ? { username: context.options.merchantUsername, password: context.options.merchantPassword }
|
||||
: { username: process.env.MERCHANT_TEST_USERNAME, password: process.env.MERCHANT_TEST_PASSWORD }
|
||||
if (!credentials.username || !credentials.password) throw new Error('Client 当前运行缺少完整登录凭据')
|
||||
const loginResponse = await context.request('POST', '/api/auth/login', {
|
||||
loginId: MERCHANT_USERNAME, password: MERCHANT_PASSWORD,
|
||||
loginId: credentials.username, password: credentials.password,
|
||||
}, '')
|
||||
const login = context.apiSuccess(MODULE, 'Client 测试账号登录', loginResponse,
|
||||
`username=${MERCHANT_USERNAME}`)
|
||||
'current-run customer credentials')
|
||||
const merchantToken = login?.accessToken
|
||||
if (!merchantToken) throw new Error('Client 登录未返回 accessToken')
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// Explicit TEST-only runtime controller. Importing this module performs no HTTP.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { open, mkdir } from 'node:fs/promises'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { assertOrderDependencyFaultObservation, orderPersistenceFacts } from './admin-test-env-modules/orders.mjs'
|
||||
|
||||
const runtime = readFileSync(new URL('./order-submit-dependency-fault-runtime.py', import.meta.url), 'utf8')
|
||||
const proxySource = readFileSync(new URL('./order-submit-dependency-fault-proxy.py', import.meta.url)).toString('base64')
|
||||
const commonSource = readFileSync(new URL('./test-event-fault-runtime.py', import.meta.url)).toString('base64')
|
||||
const encode = value => Buffer.from(value).toString('base64')
|
||||
const sha = value => createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
||||
const assert = (ok, message) => { if (!ok) throw Error(message) }
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
const safeError = error => /^(?:[A-Z][A-Z_0-9]{1,100}|Dependency fault evidence: [A-Za-z /:-]+)$/.test(error?.message || '')
|
||||
? error.message : 'ORDER_DEPENDENCY_VERIFICATION_FAILED'
|
||||
|
||||
export function scopeFor(plan) {
|
||||
assert(/^[A-Za-z0-9_-]{1,80}$/.test(plan?.marker || '') && /^[1-9]\d{0,19}$/.test(plan?.customerId || ''), 'Invalid TEST plan scope')
|
||||
for (const role of ['merchant', 'platform']) {
|
||||
const command = plan.commands?.[role]
|
||||
assert(command?.method === 'POST' && /^[1-9]\d{0,19}$/.test(command.orderId || '')
|
||||
&& command.path === `/api/${role}/v1/orders/first-leg/${command.orderId}/submit${role === 'platform' ? `?tenantId=${plan.customerId}` : ''}`
|
||||
&& Number.isSafeInteger(command.body?.expectedVersion) && command.body.expectedVersion >= 0
|
||||
&& Object.keys(command.body).length === 1 && /^[A-Za-z0-9_-]{1,128}$/.test(command.headers?.['Idempotency-Key'] || '')
|
||||
&& Object.keys(command.headers).length === 1
|
||||
&& /^[0-9a-f]{64}$/.test(command.initialOrderSha256 || ''), 'Invalid frozen submission command')
|
||||
}
|
||||
assert(plan.commands.merchant.orderId !== plan.commands.platform.orderId, 'Two distinct new drafts required')
|
||||
assert(plan.runtimeChangesExecuted === false, 'Only a reviewed unexecuted plan is accepted')
|
||||
assert(Array.isArray(plan.phases) && plan.phases.length === 2, 'Exact two dependency phases required')
|
||||
for (const [dependency, property, path, messages] of [
|
||||
['IAM','baiyue.clients.iam.url',`/internal/iam/tenants/${plan.customerId}/order-eligibility`,['IAM eligibility service is unavailable','IAM 下单资格响应无效']],
|
||||
['FINANCE','baiyue.clients.finance.url',`/internal/finance/v1/wallet/accounts/${plan.customerId}`,['Finance account service is unavailable','Finance account response is invalid']],
|
||||
]) {
|
||||
const rows = plan.phases.filter(row => row.dependency === dependency)
|
||||
assert(rows.length === 1 && rows[0].urlProperty === property && rows[0].targetPath === path && rows[0].targetMethod === 'GET'
|
||||
&& rows[0].faultHttp === 503 && rows[0].expectedHttp === 200 && rows[0].expectedCode === 'S0503'
|
||||
&& JSON.stringify(rows[0].expectedMessages) === JSON.stringify(messages), 'Dependency failure contract may not be weakened')
|
||||
}
|
||||
return { marker: plan.marker, customerId: plan.customerId,
|
||||
orderIds: Object.fromEntries(['merchant', 'platform'].map(role => [role, plan.commands[role].orderId])), planSha256: sha(plan) }
|
||||
}
|
||||
|
||||
export function renderOrderFaultAction(action, scope, agent, extra = {}) {
|
||||
assert(['preflight','prepare','activate','health','mode','status','records','restore','reconcile','verify-restored','finish'].includes(action), 'Unknown runtime action')
|
||||
assert(/^[A-Za-z0-9_.-]{1,128}$/.test(agent || '') && /^[A-Za-z0-9_-]{1,80}$/.test(scope.marker), 'Invalid agent/scope')
|
||||
if (['prepare','preflight'].includes(action)) {
|
||||
const routing = extra.routing
|
||||
assert(routing && Object.keys(routing).every(key => ['configurationReviewed','sourceSha256','upstreams','nacos'].includes(key))
|
||||
&& routing.configurationReviewed === true && routing.nacos && Object.keys(routing.nacos).every(key => ['baseUrl','loginPath','configPath'].includes(key))
|
||||
&& routing.upstreams && Object.keys(routing.upstreams).sort().join(',') === 'FINANCE,IAM', 'Reviewed routing schema only; do not embed credentials')
|
||||
for (const address of [routing.nacos.baseUrl,...Object.values(routing.upstreams)]) {
|
||||
const url = new URL(address)
|
||||
assert(['http:','https:'].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash
|
||||
&& ['', '/'].includes(url.pathname), 'Routing origins cannot contain credentials/path/query')
|
||||
}
|
||||
if (action === 'prepare') assert(/^sha256:[0-9a-f]{64}$/.test(extra.expectedImageId || '')
|
||||
&& /^[0-9a-f]{64}$/.test(routing.sourceSha256 || ''), 'Reviewed image and Nacos source fingerprint required')
|
||||
}
|
||||
const data = encode(JSON.stringify({ ...extra, action, scope, commonSource, ...(action === 'prepare' ? { proxySource } : {}) }))
|
||||
const expression = value => '[' + encode(value).match(/.{1,4000}/g).map(part => `'${part}'`).join(',') + "].join('')"
|
||||
const groovy = `def p=new ProcessBuilder(['python3','-c',new String(java.util.Base64.decoder.decode(${expression(runtime)}),'UTF-8'),'${data}']).redirectErrorStream(true).start();def out=p.inputStream.getText('UTF-8');p.waitFor();print(out)`
|
||||
return `def c=jenkins.model.Jenkins.instance.getComputer('${agent}');if(c==null || c.channel==null)throw new RuntimeException('TEST agent unavailable');print(hudson.util.RemotingDiagnostics.executeGroovy(new String(java.util.Base64.decoder.decode(${expression(groovy)}),'UTF-8'),c.channel))`
|
||||
}
|
||||
|
||||
export function createOrderFaultRuntime(env, scope, fetcher = globalThis.fetch) {
|
||||
assert(env.CHONGCHONG_ENV === 'test', 'CHONGCHONG_ENV=test required')
|
||||
assert(env.JENKINS_URL && env.JENKINS_USER && env.JENKINS_API_TOKEN && env.FBA_CORE_JENKINS_AGENT, 'Explicit TEST Jenkins credentials and agent required')
|
||||
return async (action, extra = {}) => {
|
||||
let response
|
||||
try {
|
||||
response = await fetcher(new URL('scriptText', env.JENKINS_URL.replace(/\/?$/, '/')), {
|
||||
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(55000),
|
||||
headers: { Authorization: 'Basic ' + encode(env.JENKINS_USER + ':' + env.JENKINS_API_TOKEN), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ script: renderOrderFaultAction(action, scope, env.FBA_CORE_JENKINS_AGENT, extra) }),
|
||||
})
|
||||
} catch { throw Error(`RUNTIME_${action.toUpperCase().replaceAll('-', '_')}_OUTCOME_UNKNOWN_USE_STATUS`) }
|
||||
assert(response.ok, `RUNTIME_HTTP_${response.status}_NO_AUTO_RETRY`)
|
||||
const rows = (await response.text()).split(/\r?\n/).filter(row => row.startsWith('ORDER_DEPENDENCY_JSON='))
|
||||
assert(rows.length === 1, 'RUNTIME_RESPONSE_AMBIGUOUS_NO_AUTO_RETRY')
|
||||
const result = JSON.parse(rows[0].slice('ORDER_DEPENDENCY_JSON='.length))
|
||||
assert(result?.ok === true, /^[A-Z_0-9]{1,90}$/.test(result?.error || '') ? result.error : 'RUNTIME_ACTION_FAILED')
|
||||
return result.data
|
||||
}
|
||||
}
|
||||
|
||||
export async function runOrderFaultWindow({ plan, routing, expectedImageId, action, snapshot, submit, recovery, wait = pause }) {
|
||||
const scope = scopeFor(plan), report = { scope, success: false, observations: [], restoration: null }
|
||||
let prepareAttempted = false, failure
|
||||
const health = async () => {
|
||||
for (let i = 0; i < 180; i++) {
|
||||
const value = await action('health')
|
||||
if (value.healthy === true) return
|
||||
await wait(1000)
|
||||
}
|
||||
throw Error('ORDER_HEALTH_TIMEOUT')
|
||||
}
|
||||
try {
|
||||
for (const role of ['merchant','platform']) {
|
||||
const before = await snapshot(role)
|
||||
assert(createHash('sha256').update(orderPersistenceFacts(before.detail)).digest('hex') === plan.commands[role].initialOrderSha256
|
||||
&& before.detail.order.status === 'DRAFT' && String(before.detail.order.tenantId) === scope.customerId
|
||||
&& String(before.detail.order.id) === scope.orderIds[role]
|
||||
&& ['cargoes','parties','internalNotes','statusHistory'].every(field => Array.isArray(before.detail[field]))
|
||||
&& before.inboundPageComplete === true, 'PREPARED_DRAFT_CHANGED')
|
||||
}
|
||||
prepareAttempted = true
|
||||
report.prepared = await action('prepare', { routing, expectedImageId })
|
||||
for (let i = 0; i < 15; i++) {
|
||||
if ((await action('status')).proxy?.mode === 'PASS') break
|
||||
assert(i < 14, 'PROXY_STARTUP_NOT_READY'); await wait(1000)
|
||||
}
|
||||
await action('activate'); await health()
|
||||
for (const dependency of ['IAM','FINANCE']) {
|
||||
for (const role of ['merchant','platform']) {
|
||||
const before = await snapshot(role), command = plan.commands[role]
|
||||
const request = { method: command.method, path: command.path, body: structuredClone(command.body),
|
||||
headers: { ...command.headers, 'X-Request-Id': `dep-${plan.marker}-${dependency}-${role}` } }
|
||||
let response
|
||||
await action('mode', { mode: dependency, ttl: 60 })
|
||||
try { response = await submit(role, request) }
|
||||
finally { await action('mode', { mode: 'PASS' }) }
|
||||
const after = await snapshot(role)
|
||||
const proxyRecords = (await action('records')).records.filter(row => row.dependency === dependency)
|
||||
report.observations.push(assertOrderDependencyFaultObservation(plan, dependency, role, { request, response, before, after, proxyRecords }))
|
||||
}
|
||||
}
|
||||
} catch (error) { failure = error }
|
||||
finally {
|
||||
if (prepareAttempted) {
|
||||
try {
|
||||
await action('restore') // Never retry a mutation after an ambiguous outcome.
|
||||
await health()
|
||||
report.restoredRuntime = await action('verify-restored')
|
||||
assert(report.restoredRuntime.originalImageId === expectedImageId
|
||||
&& report.restoredRuntime.currentImageId === expectedImageId
|
||||
&& report.restoredRuntime.originalFingerprint === report.restoredRuntime.currentFingerprint
|
||||
&& report.restoredRuntime.nacosSourcesUnchanged === true
|
||||
&& report.restoredRuntime.upstreamReads?.IAM === true && report.restoredRuntime.upstreamReads?.FINANCE === true,
|
||||
'RESTORED_IDENTITY_OR_ROUTES_NOT_PROVED')
|
||||
const businessRecovery = await recovery()
|
||||
report.businessRecovery = businessRecovery
|
||||
report.restoration = await action('finish', { businessRecovery })
|
||||
assert(report.restoration.restored && report.restoration.proxyStopped && report.restoration.orderRoutesVerified, 'RESTORATION_NOT_COMPLETE')
|
||||
} catch (error) { report.recoveryError = safeError(error); failure ||= error }
|
||||
}
|
||||
}
|
||||
report.success = !failure && report.observations.length === 4 && Boolean(report.restoration?.restored)
|
||||
if (failure) report.error = safeError(failure)
|
||||
return report
|
||||
}
|
||||
|
||||
function businessClient(env, plan, fetcher = globalThis.fetch) {
|
||||
const sessions = {}
|
||||
const call = async (method, path, body, headers = {}, token) => {
|
||||
const response = await fetcher(new URL(path, env.ADMIN_TEST_GATEWAY), { method, redirect:'error', signal:AbortSignal.timeout(20000),
|
||||
headers:{'Content-Type':'application/json', ...headers, ...(token ? {Authorization:`Bearer ${token}`} : {})}, ...(body === undefined ? {} : {body:JSON.stringify(body)}) })
|
||||
return {http:response.status,body:await response.json()}
|
||||
}
|
||||
const ok = value => { assert(value.http === 200 && String(value.body?.code) === '200', 'BUSINESS_READ_OR_RECOVERY_FAILED'); return value.body.data }
|
||||
const snapshot = async role => {
|
||||
const command = plan.commands[role]
|
||||
const detailPath = `/api/${role}/v1/orders/first-leg/${command.orderId}${role === 'platform' ? `?tenantId=${plan.customerId}` : ''}`
|
||||
const detail = ok(await call('GET',detailPath,undefined,{},sessions[role].accessToken))
|
||||
assert(String(detail?.order?.id)===command.orderId && String(detail.order.tenantId)===plan.customerId
|
||||
&& ['cargoes','parties','internalNotes','statusHistory'].every(field=>Array.isArray(detail[field])), 'ORDER_SNAPSHOT_INCOMPLETE_OR_FOREIGN')
|
||||
const taskResponse = await call('GET',`/api/platform/v1/first-leg/tasks/by-order/${command.orderId}?tenantId=${plan.customerId}`,undefined,{},sessions.platform.accessToken)
|
||||
assert(taskResponse.http === 200 && ['200','A0404'].includes(String(taskResponse.body?.code)), 'TASK_SNAPSHOT_FAILED')
|
||||
const task = String(taskResponse.body.code) === '200' ? taskResponse.body.data ?? null : null
|
||||
const inbounds = []; let total
|
||||
for (let page=1;;page++) {
|
||||
const value=ok(await call('GET',`/api/platform/inbounds?tenantId=${plan.customerId}&orderNo=${encodeURIComponent(command.orderNo)}&page=${page}&size=200`,undefined,{},sessions.platform.accessToken))
|
||||
assert(Array.isArray(value.records) && Number(value.page)===page && Number.isSafeInteger(Number(value.total)), 'INBOUND_PAGE_INVALID')
|
||||
total ??= Number(value.total); assert(Number(value.total)===total && value.records.length===Math.min(200,Math.max(0,total-inbounds.length)), 'INBOUND_PAGE_INCOMPLETE')
|
||||
for(const row of value.records)assert(row.orderNo===command.orderNo && String(row.tenantId)===plan.customerId && !inbounds.some(before=>String(before.id)===String(row.id)), 'INBOUND_SCOPE_OR_DUPLICATE')
|
||||
inbounds.push(...value.records);if(inbounds.length===total)break
|
||||
}
|
||||
return {detail,task,inbounds,inboundPageComplete:true}
|
||||
}
|
||||
const submit = (role, request) => call(request.method,request.path,request.body,request.headers,sessions[role].accessToken)
|
||||
return { snapshot, submit,
|
||||
async login() {
|
||||
for(const [role,prefix] of [['platform','ADMIN_TEST'],['merchant','MERCHANT_TEST']]) {
|
||||
assert(env[prefix+'_USERNAME'] && env[prefix+'_PASSWORD'], 'EXPLICIT_BUSINESS_CREDENTIALS_REQUIRED')
|
||||
sessions[role]=ok(await call('POST','/api/auth/login',{loginId:env[prefix+'_USERNAME'],password:env[prefix+'_PASSWORD']}))
|
||||
}
|
||||
const profile=ok(await call('GET','/api/merchant/v1/iam/tenant/profile',undefined,{},sessions.merchant.accessToken))
|
||||
assert(String(profile.id)===plan.customerId && profile.status==='ACTIVE','REGISTERED_CUSTOMER_SESSION_MISMATCH')
|
||||
},
|
||||
async recovery() {
|
||||
const evidence={}
|
||||
for(const role of ['merchant','platform']) {
|
||||
const command=plan.commands[role]
|
||||
ok(await submit(role,command))
|
||||
let before=await snapshot(role)
|
||||
for(let i=0;i<10;i++) { await pause(500);const next=await snapshot(role);if(sha(before)===sha(next)){before=next;break}before=next }
|
||||
assert(before.detail.order.status==='SUBMITTED' && before.detail.statusHistory.filter(row=>row.action==='SUBMITTED').length===1
|
||||
&& before.inbounds.length<=1, 'RECOVERY_SINGLE_SUBMISSION_NOT_PROVED')
|
||||
ok(await submit(role,command));const after=await snapshot(role)
|
||||
assert(sha(before)===sha(after),'RECOVERY_REPLAY_DRIFT')
|
||||
evidence[role]={orderId:command.orderId,status:'SUBMITTED',sameKeyReplayVerified:true,snapshotSha256:sha(after)}
|
||||
}
|
||||
return evidence
|
||||
},
|
||||
async logout() {
|
||||
let success=true
|
||||
for(const session of Object.values(sessions))try {ok(await call('POST','/api/auth/logout',{refreshToken:session.refreshToken},{},session.accessToken))}catch{success=false}
|
||||
return success
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function main(argv) {
|
||||
const [mode,...rest]=argv, args={}
|
||||
for(let i=0;i<rest.length;i+=2){assert(rest[i]?.startsWith('--') && rest[i+1] && !rest[i+1].startsWith('--'),'Use explicit --name value arguments');args[rest[i].slice(2)]=rest[i+1]}
|
||||
assert(['plan','preflight','run','status','reconcile','restore'].includes(mode),'Use plan/preflight/run/status/reconcile/restore')
|
||||
assert(args.plan,'--plan required');const plan=JSON.parse(readFileSync(args.plan,'utf8')),scope=scopeFor(plan)
|
||||
if(mode==='plan'){console.log(JSON.stringify({scope,actions:['prepare','activate','IAM x 2','FINANCE x 2','finally restore','verify original routes','finish proxy'],requiresExclusiveOrderWindow:true},null,2));return}
|
||||
assert(args['confirm-test-runtime']==='yes','--confirm-test-runtime yes required')
|
||||
const action=createOrderFaultRuntime(process.env,scope)
|
||||
if(mode==='preflight'){assert(args.routing,'--routing required');console.log(JSON.stringify(await action('preflight',{routing:JSON.parse(readFileSync(args.routing,'utf8'))}),null,2));return}
|
||||
if(['status','reconcile'].includes(mode)){console.log(JSON.stringify(await action(mode),null,2));return}
|
||||
assert(args.report && process.env.ADMIN_TEST_GATEWAY,'--report and ADMIN_TEST_GATEWAY required')
|
||||
await mkdir(dirname(resolve(args.report)),{recursive:true})
|
||||
const reportFile=await open(resolve(args.report),'wx',0o600) // Never overwrite a previous run or recovery report.
|
||||
const business=businessClient(process.env,plan);let result
|
||||
try {
|
||||
await business.login()
|
||||
if(mode==='run') {
|
||||
assert(args.routing && /^sha256:[0-9a-f]{64}$/.test(args['expected-image']||''),'--routing and immutable --expected-image required')
|
||||
result=await runOrderFaultWindow({plan,routing:JSON.parse(readFileSync(args.routing,'utf8')),expectedImageId:args['expected-image'],action,...business})
|
||||
} else {
|
||||
await action('restore')
|
||||
for(let i=0;i<180;i++){if((await action('health')).healthy)break;assert(i<179,'RESTORE_HEALTH_TIMEOUT');await pause(1000)}
|
||||
const runtime=await action('verify-restored'),businessRecovery=await business.recovery()
|
||||
result={success:true,runtime,businessRecovery,restoration:await action('finish',{businessRecovery})}
|
||||
}
|
||||
} catch(error){result={success:false,error:/^[A-Z_0-9 :.-]+$/.test(error.message)?error.message:'EXECUTION_FAILED_USE_RUNTIME_STATUS'}}
|
||||
finally {
|
||||
const sessionsClosed=await business.logout();result ??={success:false};result.scope=scope;result.sessionsClosed=sessionsClosed
|
||||
if(!sessionsClosed)result.success=false
|
||||
try {await reportFile.writeFile(JSON.stringify(result,null,2)+'\n');await reportFile.sync()}
|
||||
finally {await reportFile.close()}
|
||||
}
|
||||
console.log(JSON.stringify({success:result.success,report:resolve(args.report),restored:result.restoration?.restored===true}))
|
||||
if(!result.success)process.exitCode=1
|
||||
}
|
||||
|
||||
if(process.argv[1] && import.meta.url===pathToFileURL(resolve(process.argv[1])).href)main(process.argv.slice(2)).catch(()=>{console.error('ORDER_DEPENDENCY_ARGUMENT_OR_CONFIGURATION_ERROR');process.exitCode=1})
|
||||
@@ -0,0 +1,209 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { buildOrderDependencyFaultPlan } from './admin-test-env-modules/orders.mjs'
|
||||
import { scopeFor, renderOrderFaultAction, createOrderFaultRuntime, runOrderFaultWindow } from './order-submit-dependency-fault-executor.mjs'
|
||||
|
||||
const image = 'sha256:' + 'a'.repeat(64)
|
||||
function fixture() {
|
||||
const detail = id => ({order:{id,tenantId:'20',orderNo:`FL${id}`,version:2,status:'DRAFT'},cargoes:[],parties:[],internalNotes:[],statusHistory:[]})
|
||||
const raw={marker:'TEST-DEPENDENCY',customerId:'20',registeredCustomerId:'20',merchant:detail('101'),platform:detail('102')}
|
||||
return {raw,plan:buildOrderDependencyFaultPlan(raw)}
|
||||
}
|
||||
const env={CHONGCHONG_ENV:'test',JENKINS_URL:'https://offline.invalid/',JENKINS_USER:'offline',JENKINS_API_TOKEN:'never-print-this',FBA_CORE_JENKINS_AGENT:'test-agent'}
|
||||
|
||||
test('renderer binds TEST scope and emits no Jenkins credential; ambiguous remote mutation is never retried',async()=>{
|
||||
const {plan}=fixture(),scope=scopeFor(plan)
|
||||
assert.ok(!renderOrderFaultAction('status',scope,env.FBA_CORE_JENKINS_AGENT).includes(env.JENKINS_API_TOKEN))
|
||||
assert.throws(()=>renderOrderFaultAction('delete-all',scope,'agent'))
|
||||
assert.throws(()=>renderOrderFaultAction('prepare',scope,'agent',{routing:{configurationReviewed:true,password:'must-not-embed'}}))
|
||||
assert.throws(()=>createOrderFaultRuntime({...env,CHONGCHONG_ENV:'prod'},scope))
|
||||
for(const mutate of [p=>p.phases[0].expectedCode='A0403',p=>p.phases[0].targetPath+='/other',p=>p.phases[0].expectedMessages.push('anything')]) {
|
||||
const changed=structuredClone(plan);mutate(changed);assert.throws(()=>scopeFor(changed))
|
||||
}
|
||||
let calls=0
|
||||
const runtime=createOrderFaultRuntime(env,scope,async()=>{calls++;throw Error('connection lost with secret')})
|
||||
await assert.rejects(runtime('activate'),/OUTCOME_UNKNOWN_USE_STATUS/)
|
||||
assert.equal(calls,1)
|
||||
})
|
||||
|
||||
function windowFixture(fault='') {
|
||||
const {raw,plan}=fixture(),calls=[],records=[]
|
||||
let mode='PASS'
|
||||
const options={plan,routing:{configurationReviewed:true},expectedImageId:image,wait:async()=>{},
|
||||
async action(action,extra={}) {
|
||||
calls.push(action)
|
||||
if(action==='prepare'&&fault==='prepare-unknown')throw Error('PREPARE_OUTCOME_UNKNOWN')
|
||||
if(action==='restore'&&['restore-unknown','prepare-unknown'].includes(fault))throw Error('UNCERTAIN_MUTATION_REQUIRES_RECONCILE')
|
||||
if(action==='status')return {proxy:{mode:'PASS'}}
|
||||
if(action==='health')return {healthy:true}
|
||||
if(action==='mode'){mode=extra.mode;return {mode}}
|
||||
if(action==='records')return {records}
|
||||
if(action==='verify-restored')return {originalImageId:image,currentImageId:image,originalFingerprint:'same',currentFingerprint:'same',upstreamReads:{IAM:true,FINANCE:true},nacosSourcesUnchanged:true}
|
||||
if(action==='finish')return {restored:true,healthy:true,proxyStopped:true,orderRoutesVerified:true}
|
||||
return {}
|
||||
},
|
||||
async snapshot(role){return {detail:structuredClone(raw[role]),task:null,inbounds:[],inboundPageComplete:true}},
|
||||
async submit(role,request){
|
||||
const phase=plan.phases.find(item=>item.dependency===mode)
|
||||
records.push({requestId:request.headers['X-Request-Id'],dependency:mode,method:'GET',path:phase.targetPath,
|
||||
matched:true,responseStatus:503,forwarded:false,internalTokenPresent:true})
|
||||
return {http:200,body:{code:fault==='wrong-business-code'?'A0403':'S0503',message:phase.expectedMessages[0]}}
|
||||
},
|
||||
async recovery(){calls.push('business-recovery');if(fault==='business-recovery-failed')throw Error('RECOVERY_REPLAY_DRIFT')
|
||||
return {merchant:{orderId:'101',status:'SUBMITTED',sameKeyReplayVerified:true},platform:{orderId:'102',status:'SUBMITTED',sameKeyReplayVerified:true}}},
|
||||
}
|
||||
return {options,calls}
|
||||
}
|
||||
|
||||
test('four observations restore original routes and verify business recovery before stopping proxy',async()=>{
|
||||
const {options,calls}=windowFixture(),report=await runOrderFaultWindow(options)
|
||||
assert.equal(report.success,true);assert.equal(report.observations.length,4)
|
||||
assert.ok(calls.indexOf('restore')<calls.indexOf('verify-restored'))
|
||||
assert.ok(calls.indexOf('verify-restored')<calls.indexOf('business-recovery'))
|
||||
assert.ok(calls.indexOf('business-recovery')<calls.indexOf('finish'))
|
||||
})
|
||||
test('failed business assertion still restores, but never reports fault verification success',async()=>{
|
||||
const {options,calls}=windowFixture('wrong-business-code'),report=await runOrderFaultWindow(options)
|
||||
assert.equal(report.success,false);assert.equal(report.restoration.restored,true)
|
||||
assert.ok(calls.includes('finish'))
|
||||
})
|
||||
for(const fault of ['prepare-unknown','restore-unknown','business-recovery-failed'])test(`unsafe cleanup blocks finish: ${fault}`,async()=>{
|
||||
const {options,calls}=windowFixture(fault),report=await runOrderFaultWindow(options)
|
||||
assert.equal(report.success,false);assert.equal(calls.filter(action=>action==='prepare').length,1)
|
||||
assert.equal(calls.filter(action=>action==='restore').length,1);assert.ok(!calls.includes('finish'))
|
||||
})
|
||||
|
||||
test('offline runtime journals Docker transitions, reconciles observed outcomes and retains original configuration (no Docker/HTTP)',()=>{
|
||||
const script=String.raw`
|
||||
import importlib.util,sys,copy,json,unittest
|
||||
from pathlib import Path
|
||||
spec=importlib.util.spec_from_file_location('runtime',sys.argv[1]);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
common=m.common_tools(Path(sys.argv[2]).read_text(encoding='utf-8'))
|
||||
class Fake:
|
||||
def __init__(self):
|
||||
self.current={'Id':'original-container-id','Name':'/firstleg-test-order','Image':'sha256:'+'a'*64,
|
||||
'Config':{'Image':'offline/order:tag','Env':['DEPLOY_ENV=test','SERVER_PORT=9204','INTERNAL_SERVICE_TOKEN=offline-secret-long'],
|
||||
'Entrypoint':copy.deepcopy(m.ENTRYPOINT),'Cmd':None,'Labels':{'com.docker.compose.project':'firstleg-test'}},
|
||||
'HostConfig':{'AutoRemove':False,'ExtraHosts':['host.docker.internal:host-gateway']},'Mounts':[],
|
||||
'NetworkSettings':{'Networks':{'test':{'IPAddress':'172.20.0.10','NetworkID':'n1','Aliases':['order','original-con'],'MacAddress':'aa:bb:cc:dd:ee:ff'}}},
|
||||
'State':{'Running':True,'Health':{'Status':'healthy'}}}
|
||||
self.original=copy.deepcopy(self.current);self.calls=[];self.fail=None;self.failBefore=None;self.serial=0
|
||||
def inspect(self,name):
|
||||
if not self.current or name not in ('firstleg-test-order',self.current['Id']):raise RuntimeError('DOCKER_404')
|
||||
return copy.deepcopy(self.current)
|
||||
def docker(self,method,path,body=None):
|
||||
if method=='GET':return {'Id':self.original['Image']}
|
||||
self.calls.append((method,path));result=None
|
||||
if self.failBefore=='stop' and '/stop?' in path:raise RuntimeError('REQUEST_OUTCOME_UNKNOWN')
|
||||
if '/stop?' in path:self.current['State']['Running']=False;op='stop'
|
||||
elif method=='DELETE':self.current=None;op='delete'
|
||||
elif '/create?' in path:
|
||||
self.serial+=1;new_id='new-container-'+str(self.serial);op='create'
|
||||
self.current={'Id':new_id,'Name':'/firstleg-test-order','Image':self.original['Image'],
|
||||
'Config':{k:copy.deepcopy(v) for k,v in body.items() if k not in ('HostConfig','NetworkingConfig')},
|
||||
'HostConfig':copy.deepcopy(body['HostConfig']),'Mounts':[],
|
||||
'NetworkSettings':{'Networks':{}},'State':{'Running':False,'Health':{'Status':'healthy'}}}
|
||||
for name,entry in body['NetworkingConfig']['EndpointsConfig'].items():
|
||||
self.current['NetworkSettings']['Networks'][name]={'IPAddress':entry['IPAMConfig']['IPv4Address'],'NetworkID':'n1',
|
||||
'Aliases':entry['Aliases']+[new_id[:12]],'MacAddress':entry['MacAddress'],'IPAMConfig':entry['IPAMConfig']}
|
||||
result={'Id':new_id}
|
||||
elif path.endswith('/start'):self.current['State']['Running']=True;op='start'
|
||||
else:raise AssertionError(path)
|
||||
if self.fail==op:self.fail=None;raise RuntimeError('RESPONSE_LOST')
|
||||
return result
|
||||
class RuntimeTests(unittest.TestCase):
|
||||
def fixture(self):
|
||||
f=Fake();tools=dict(common);tools.update(inspect=f.inspect,docker=f.docker)
|
||||
scope={'marker':'OFFLINE','customerId':'7','orderIds':{'merchant':'10','platform':'11'},'planSha256':'b'*64}
|
||||
r=m.Runtime(scope,tools,Path('offline-not-created'));saved=[];controls=[]
|
||||
r.save=lambda state:saved.append(copy.deepcopy(state));r.control=lambda state,mode=None,ttl=0:(controls.append(mode) or {'mode':'PASS'})
|
||||
r.probes=lambda state:{'IAM':True,'FINANCE':True}
|
||||
r.config_fingerprint=lambda container,routing:'reviewed-config'
|
||||
state={'scope':scope,'phase':'PROXY_STARTED','originalId':f.original['Id'],'originalImageId':f.original['Image'],
|
||||
'originalFingerprint':common['fingerprint'](f.original),'originalBody':common['recreation'](f.original,common['env_of'](f.original),full=True),
|
||||
'proxyPort':12345,'controlPort':12346,'mutation':None,'routing':{},'routingSourceSha256':'reviewed-config'}
|
||||
return f,r,state,saved,controls
|
||||
def test_original_fingerprint_config_and_image_restored(self):
|
||||
f,r,s,saved,controls=self.fixture();r.activate(s)
|
||||
self.assertTrue(r.matches_injected(f.current,s));self.assertEqual(s['phase'],'INJECTED')
|
||||
self.assertTrue(any(x.get('mutation',{}).get('step')=='CREATE_INTENT' for x in saved if x.get('mutation')))
|
||||
r.restore(s);result=r.verify_restored(s)
|
||||
self.assertEqual(result['originalFingerprint'],result['currentFingerprint']);self.assertEqual(f.current['Config'],f.original['Config'])
|
||||
def test_unknown_outcomes_never_auto_repeat_docker_and_can_be_explicitly_reconciled(self):
|
||||
for operation in ['stop','delete','create','start']:
|
||||
f,r,s,saved,controls=self.fixture();f.fail=operation
|
||||
with self.assertRaisesRegex(RuntimeError,'UNCERTAIN'):r.activate(s)
|
||||
count=len(f.calls)
|
||||
with self.assertRaisesRegex(RuntimeError,'RECONCILE'):r.restore(s)
|
||||
self.assertEqual(len(f.calls),count);self.assertEqual(controls[-1],'PASS')
|
||||
r.reconcile(s);self.assertEqual(len(f.calls),count)
|
||||
r.restore(s);self.assertTrue(r.verify_restored(s)['healthy'])
|
||||
def test_unrecognized_configuration_is_never_overwritten(self):
|
||||
f,r,s,saved,controls=self.fixture();r.activate(s);f.current['Config']['Env'].append('UNRELATED_CHANGE=1');count=len(f.calls)
|
||||
with self.assertRaisesRegex(RuntimeError,'UNRECOGNIZED'):r.restore(s)
|
||||
self.assertEqual(len(f.calls),count)
|
||||
def test_unknown_stop_without_observed_stop_is_not_misreported_as_restored(self):
|
||||
f,r,s,saved,controls=self.fixture();f.failBefore='stop'
|
||||
with self.assertRaisesRegex(RuntimeError,'UNCERTAIN'):r.activate(s)
|
||||
count=len(f.calls)
|
||||
with self.assertRaisesRegex(RuntimeError,'NO_BLIND_RECREATE'):r.reconcile(s)
|
||||
self.assertTrue(s['uncertain']);self.assertTrue(f.current['State']['Running']);self.assertEqual(len(f.calls),count)
|
||||
def test_restore_still_runs_when_proxy_control_is_unreachable(self):
|
||||
f,r,s,saved,controls=self.fixture();r.activate(s)
|
||||
def unavailable(*args):raise RuntimeError('CONTROL_LOST')
|
||||
r.control=unavailable;r.restore(s);self.assertTrue(r.verify_restored(s)['healthy'])
|
||||
def test_finish_refuses_unproved_business_route_recovery(self):
|
||||
f,r,s,saved,controls=self.fixture();r.activate(s);r.restore(s)
|
||||
from unittest.mock import patch
|
||||
with patch.object(Path,'read_text',return_value='OFFLINE'),patch.object(Path,'is_symlink',return_value=False):
|
||||
with self.assertRaisesRegex(RuntimeError,'ROUTES_NOT_PROVED'):r.finish(s,{})
|
||||
def test_changed_nacos_configuration_prevents_recovery_claim(self):
|
||||
f,r,s,saved,controls=self.fixture();r.activate(s);r.restore(s);r.config_fingerprint=lambda *args:'changed'
|
||||
with self.assertRaisesRegex(RuntimeError,'NACOS_ROUTE_CONFIGURATION_CHANGED'):r.verify_restored(s)
|
||||
def test_nacos_source_reads_are_explicit_authenticated_and_redacted(self):
|
||||
from unittest.mock import patch
|
||||
f,r,s,saved,controls=self.fixture()
|
||||
f.current['Config']['Env']+=['NACOS_SERVER_ADDR=host.docker.internal:8848','NACOS_USERNAME=offline-user',
|
||||
'NACOS_PASSWORD=offline-password','NACOS_GROUP=BAIYUE','NACOS_NAMESPACE=test-only']
|
||||
routing={'nacos':{'baseUrl':'http://127.0.0.1:8848','loginPath':'/nacos/v1/auth/users/login','configPath':'/nacos/v1/cs/configs'}}
|
||||
calls=[]
|
||||
class Response:
|
||||
status=200
|
||||
def __init__(self,value):self.value=value
|
||||
def __enter__(self):return self
|
||||
def __exit__(self,*args):pass
|
||||
def read(self):return self.value
|
||||
def read(req,timeout):
|
||||
calls.append(req)
|
||||
return Response(b'{"accessToken":"offline-token"}' if not isinstance(req,str) else b'spring:\n test: only\n')
|
||||
with patch.object(m,'http_open',side_effect=read):
|
||||
value=m.Runtime.config_fingerprint(r,f.current,routing)
|
||||
self.assertEqual(len(value),64);self.assertEqual(len(calls),3)
|
||||
self.assertIn(b'password=offline-password',calls[0].data)
|
||||
self.assertTrue(all('accessToken=offline-token' in url for url in calls[1:]))
|
||||
routing['nacos']['baseUrl']='http://other.invalid:8848'
|
||||
with self.assertRaisesRegex(RuntimeError,'EXPLICIT_NACOS'):m.Runtime.config_fingerprint(r,f.current,routing)
|
||||
self.assertEqual(len(calls),3)
|
||||
def test_effective_source_extraction_excludes_all_event_and_sql_functions(self):
|
||||
self.assertEqual(set(common),{'DockerConnection','docker','inspect','env_of','require_test','recreation','fingerprint'})
|
||||
def test_control_token_is_not_sent_to_an_unowned_listener(self):
|
||||
from unittest.mock import patch
|
||||
f,r,s,saved,controls=self.fixture();s.update(proxyPid=777,controlToken='private-control')
|
||||
command=str(r.directory/'proxy.py').encode()+b'\0'
|
||||
tcp='header\n 0: 00000000:3039 00000000:0000 0A 0:0 0:0 0 1000 0 123\n'
|
||||
with patch.object(Path,'read_bytes',return_value=command),patch.object(Path,'iterdir',return_value=iter([Path('fd')])), \
|
||||
patch.object(m.os,'readlink',return_value='socket:[123]'),patch.object(Path,'read_text',return_value=tcp), \
|
||||
patch.object(m,'http_open') as request:
|
||||
with self.assertRaisesRegex(RuntimeError,'LISTENERS_NOT_OWNED'):m.Runtime.control(r,s)
|
||||
request.assert_not_called()
|
||||
result=unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(RuntimeTests))
|
||||
sys.exit(0 if result.wasSuccessful() else 1)
|
||||
`
|
||||
const result=spawnSync(process.env.PYTHON||'python',['-c',script,
|
||||
fileURLToPath(new URL('./order-submit-dependency-fault-runtime.py',import.meta.url)),
|
||||
fileURLToPath(new URL('./test-event-fault-runtime.py',import.meta.url))],{encoding:'utf8',env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'}})
|
||||
assert.equal(result.status,0,result.stderr || result.error?.message)
|
||||
assert.match(result.stderr,/Ran 10 tests/)
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
"""TEST-only scoped synchronous dependency proxy. Default/expired mode is PASS.
|
||||
|
||||
Configuration and control token are read from a mode-0600 file, never argv values.
|
||||
The runtime owns lifecycle; this file never invokes Docker, SQL or a shell.
|
||||
"""
|
||||
import argparse
|
||||
import hmac
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
HOP = {'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
||||
'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'content-length'}
|
||||
|
||||
|
||||
def forward_headers(headers, length):
|
||||
connection = {k.lower(): v for k, v in headers.items()}.get('connection', '')
|
||||
excluded = HOP | {x.strip().lower() for x in connection.split(',')}
|
||||
result = {k: v for k, v in headers.items() if k.lower() not in excluded}
|
||||
result['Content-Length'] = str(length)
|
||||
return result
|
||||
|
||||
|
||||
def transport(upstream, method, path, headers, body):
|
||||
url = urllib.parse.urlsplit(upstream)
|
||||
cls = http.client.HTTPSConnection if url.scheme == 'https' else http.client.HTTPConnection
|
||||
conn = cls(url.hostname, url.port, timeout=8)
|
||||
try:
|
||||
conn.request(method, path, body, forward_headers(headers, len(body)))
|
||||
response = conn.getresponse()
|
||||
return response.status, dict(response.getheaders()), response.read()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class Router:
|
||||
def __init__(self, config, forward=transport, clock=time.monotonic, record=None):
|
||||
self.config, self.forward, self.clock = config, forward, clock
|
||||
self.mode, self.deadline, self.generation = 'PASS', 0, 0
|
||||
self.lock = threading.Lock()
|
||||
self.record = record or (lambda row: None)
|
||||
self.counts = {'forwarded': 0, 'faulted': 0}
|
||||
customer = config['customerId']
|
||||
if not re.fullmatch(r'[1-9][0-9]{0,19}', customer):
|
||||
raise ValueError('INVALID_CUSTOMER')
|
||||
if not config.get('requestIds') or any(not re.fullmatch(r'[A-Za-z0-9_.:-]{1,128}', value) for value in config['requestIds']):
|
||||
raise ValueError('BOUNDED_REQUEST_IDS_REQUIRED')
|
||||
self.paths = {'IAM': '/internal/iam/tenants/' + customer + '/order-eligibility',
|
||||
'FINANCE': '/internal/finance/v1/wallet/accounts/' + customer}
|
||||
|
||||
def status(self):
|
||||
with self.lock:
|
||||
if self.mode != 'PASS' and self.clock() >= self.deadline:
|
||||
self.mode, self.deadline = 'PASS', 0
|
||||
return {'mode': self.mode, 'generation': self.generation,
|
||||
'remainingSeconds': max(0, self.deadline - self.clock()), **self.counts}
|
||||
|
||||
def set_mode(self, token, mode, ttl=0):
|
||||
if not hmac.compare_digest(str(token), self.config['controlToken']):
|
||||
raise PermissionError('CONTROL_AUTH_REQUIRED')
|
||||
with self.lock:
|
||||
# Even an authenticated malformed command disarms first.
|
||||
self.mode, self.deadline = 'PASS', 0
|
||||
if mode not in ('PASS', 'IAM', 'FINANCE') or (mode != 'PASS' and
|
||||
(type(ttl) is not int or not 1 <= ttl <= 60)):
|
||||
raise ValueError('INVALID_MODE_OR_TTL')
|
||||
self.mode = mode
|
||||
self.deadline = self.clock() + ttl if mode != 'PASS' else 0
|
||||
self.generation += 1
|
||||
return self.status()
|
||||
|
||||
def dispatch(self, method, path, headers, body, peer):
|
||||
prefix, separator, rest = path.lstrip('/').partition('/')
|
||||
dependency = {'iam': 'IAM', 'finance': 'FINANCE'}.get(prefix)
|
||||
if not dependency or not separator:
|
||||
return 404, {}, b'Unknown dependency route'
|
||||
actual_path = '/' + rest
|
||||
normalized = {k.lower(): v for k, v in headers.items()}
|
||||
token = normalized.get('x-internal-service-token', '')
|
||||
authenticated = bool(token) and hmac.compare_digest(token, self.config['internalToken'])
|
||||
target = method == 'GET' and actual_path == self.paths[dependency]
|
||||
request_id = normalized.get('x-request-id', '')
|
||||
selected = target and peer in self.config['orderIps'] and authenticated and request_id in self.config['requestIds']
|
||||
mode = self.status()['mode']
|
||||
fault = selected and mode == dependency
|
||||
# Only bounded correlation IDs and this customer's selected paths enter evidence.
|
||||
safe_id = request_id if re.fullmatch(r'[A-Za-z0-9_.:-]{1,128}', request_id) else None
|
||||
if fault:
|
||||
result = (503, {'Content-Type': 'text/plain'}, b'TEST dependency temporarily unavailable')
|
||||
else:
|
||||
result = self.forward(self.config['upstreams'][dependency], method, actual_path, headers, body)
|
||||
with self.lock:
|
||||
self.counts['faulted' if fault else 'forwarded'] += 1
|
||||
if selected:
|
||||
self.record({'requestId': safe_id, 'method': method, 'path': actual_path,
|
||||
'dependency': dependency, 'matched': fault, 'responseStatus': result[0],
|
||||
'forwarded': not fault, 'internalTokenPresent': True,
|
||||
'internalTokenValidated': True, 'mode': mode})
|
||||
return result
|
||||
|
||||
|
||||
def main(path):
|
||||
config_path = Path(path)
|
||||
if config_path.is_symlink() or config_path.stat().st_mode & 0o077:
|
||||
raise RuntimeError('PRIVATE_CONFIG_REQUIRED')
|
||||
config = json.loads(config_path.read_text())
|
||||
evidence_path = config_path.parent / 'proxy-evidence.jsonl'
|
||||
evidence_lock = threading.Lock()
|
||||
|
||||
def record(row):
|
||||
with evidence_lock:
|
||||
fd = os.open(evidence_path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
|
||||
with os.fdopen(fd, 'w') as stream:
|
||||
stream.write(json.dumps(row) + '\n'); stream.flush(); os.fsync(stream.fileno())
|
||||
|
||||
router = Router(config, record=record)
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
def dispatch(self):
|
||||
try:
|
||||
length = int(self.headers.get('Content-Length', '0'))
|
||||
if length < 0 or length > 2 * 1024 * 1024:
|
||||
raise ValueError('BODY_LIMIT')
|
||||
body = self.rfile.read(length)
|
||||
if self.server.control:
|
||||
token = self.headers.get('X-Order-Fault-Control', '')
|
||||
if not hmac.compare_digest(token, config['controlToken']):
|
||||
raise PermissionError('CONTROL_AUTH_REQUIRED')
|
||||
if self.command == 'GET' and self.path == '/status':
|
||||
value = router.status()
|
||||
elif self.command == 'POST' and self.path == '/mode':
|
||||
value = json.loads(body)
|
||||
value = router.set_mode(token, value.get('mode'), value.get('ttl', 0))
|
||||
else:
|
||||
raise ValueError('UNKNOWN_CONTROL')
|
||||
status, headers, response = 200, {'Content-Type': 'application/json'}, json.dumps(value).encode()
|
||||
else:
|
||||
status, headers, response = router.dispatch(self.command, self.path, dict(self.headers), body, self.client_address[0])
|
||||
except PermissionError:
|
||||
status, headers, response = 403, {}, b'Forbidden'
|
||||
except (ValueError, KeyError):
|
||||
if self.server.control and hmac.compare_digest(self.headers.get('X-Order-Fault-Control', ''), config['controlToken']):
|
||||
router.set_mode(config['controlToken'], 'PASS')
|
||||
status, headers, response = 400, {}, b'Invalid request'
|
||||
except Exception:
|
||||
status, headers, response = 502, {}, b'Upstream transport failed'
|
||||
self.send_response(status)
|
||||
for name, value in headers.items():
|
||||
if name.lower() not in HOP:
|
||||
self.send_header(name, value)
|
||||
self.send_header('Content-Length', str(len(response))); self.end_headers()
|
||||
if self.command != 'HEAD':
|
||||
self.wfile.write(response)
|
||||
|
||||
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = do_OPTIONS = dispatch
|
||||
|
||||
business = ThreadingHTTPServer(('0.0.0.0', config['proxyPort']), Handler); business.control = False
|
||||
control = ThreadingHTTPServer(('127.0.0.1', config['controlPort']), Handler); control.control = True
|
||||
(config_path.parent / 'proxy.pid').write_text(str(os.getpid()))
|
||||
os.chmod(config_path.parent / 'proxy.pid', 0o600)
|
||||
threading.Thread(target=business.serve_forever, daemon=True).start()
|
||||
control.serve_forever()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(); parser.add_argument('--config', required=True)
|
||||
main(parser.parse_args().config)
|
||||
@@ -0,0 +1,69 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
test('offline proxy routing/authentication/TTL/transparency and redaction (no sockets or HTTP)', () => {
|
||||
const script = String.raw`
|
||||
import importlib.util,json,sys,unittest
|
||||
spec=importlib.util.spec_from_file_location('proxy',sys.argv[1]);p=importlib.util.module_from_spec(spec);spec.loader.exec_module(p)
|
||||
class ProxyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.now=[10.0];self.calls=[];self.logs=[]
|
||||
self.config={'customerId':'7','controlToken':'private-control','internalToken':'private-internal-token',
|
||||
'orderIps':['172.20.0.10'],'requestIds':['fixture-request'],'upstreams':{'IAM':'http://iam.invalid:9201','FINANCE':'http://finance.invalid:9207'}}
|
||||
def forward(*args): self.calls.append(args);return 201,{'X-Upstream':'kept'},b'original-response'
|
||||
self.router=p.Router(self.config,forward=forward,clock=lambda:self.now[0],record=self.logs.append)
|
||||
self.headers={'X-Internal-Service-Token':'private-internal-token','X-Request-Id':'fixture-request'}
|
||||
self.path='/iam/internal/iam/tenants/7/order-eligibility'
|
||||
def send(self,path=None,method='GET',headers=None,peer='172.20.0.10'):
|
||||
return self.router.dispatch(method,path or self.path,headers or self.headers,b'original-body',peer)
|
||||
def test_default_pass_and_transport_are_unchanged(self):
|
||||
self.assertEqual(self.send(),(201,{'X-Upstream':'kept'},b'original-response'))
|
||||
self.assertEqual(self.calls[0][2],'/internal/iam/tenants/7/order-eligibility')
|
||||
self.assertEqual(self.calls[0][3]['X-Internal-Service-Token'],'private-internal-token')
|
||||
self.assertEqual(self.calls[0][4],b'original-body')
|
||||
def test_only_exact_authenticated_customer_get_from_order_faults(self):
|
||||
self.router.set_mode('private-control','IAM',60)
|
||||
self.assertEqual(self.send()[0],503);self.assertEqual(len(self.calls),0)
|
||||
for path,method,headers,peer in [
|
||||
(self.path.replace('/7/','/8/'),'GET',self.headers,'172.20.0.10'),
|
||||
(self.path+'?other=1','GET',self.headers,'172.20.0.10'),
|
||||
(self.path,'POST',self.headers,'172.20.0.10'),
|
||||
(self.path,'GET',{'X-Internal-Service-Token':'wrong'},'172.20.0.10'),
|
||||
(self.path,'GET',{**self.headers,'X-Request-Id':'other-order-request'},'172.20.0.10'),
|
||||
(self.path,'GET',self.headers,'172.20.0.11'),
|
||||
('/finance/internal/finance/v1/wallet/accounts/7','GET',self.headers,'172.20.0.10'),
|
||||
('/iam/internal/iam/finance-customers?page=1','GET',self.headers,'172.20.0.10')]:
|
||||
self.assertEqual(self.send(path,method,headers,peer)[0],201)
|
||||
def test_expiry_needs_no_control_request_and_returns_to_pass(self):
|
||||
self.router.set_mode('private-control','FINANCE',5)
|
||||
path='/finance/internal/finance/v1/wallet/accounts/7'
|
||||
self.assertEqual(self.send(path)[0],503)
|
||||
self.now[0]=15.0
|
||||
self.assertEqual(self.send(path)[0],201);self.assertEqual(self.router.status()['mode'],'PASS')
|
||||
def test_control_auth_and_malformed_authenticated_command(self):
|
||||
self.router.set_mode('private-control','IAM',10)
|
||||
with self.assertRaises(PermissionError):self.router.set_mode('wrong','PASS')
|
||||
for mode,ttl in [('UNKNOWN',10),('IAM',0),('IAM',61),('IAM',True)]:
|
||||
with self.assertRaises(ValueError):self.router.set_mode('private-control',mode,ttl)
|
||||
self.assertEqual(self.router.status()['mode'],'PASS')
|
||||
def test_evidence_contains_no_secret_headers_or_payload(self):
|
||||
self.router.set_mode('private-control','IAM',10);self.send()
|
||||
serialized=json.dumps(self.logs)
|
||||
for secret in ['private-control','private-internal-token','original-body']:self.assertNotIn(secret,serialized)
|
||||
self.assertTrue(self.logs[0]['internalTokenValidated']);self.assertFalse(self.logs[0]['forwarded'])
|
||||
self.assertEqual(self.logs[0]['requestId'],'fixture-request')
|
||||
def test_hop_headers_removed_but_identity_preserved(self):
|
||||
headers=p.forward_headers({'connection':'x-hop','x-hop':'drop','Host':'old','X-Internal-Service-Token':'keep','Content-Length':'99'},3)
|
||||
self.assertNotIn('x-hop',headers);self.assertNotIn('Host',headers)
|
||||
self.assertEqual(headers['X-Internal-Service-Token'],'keep');self.assertEqual(headers['Content-Length'],'3')
|
||||
result=unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(ProxyTests))
|
||||
sys.exit(0 if result.wasSuccessful() else 1)
|
||||
`
|
||||
const result = spawnSync(process.env.PYTHON || 'python', ['-c', script,
|
||||
fileURLToPath(new URL('./order-submit-dependency-fault-proxy.py', import.meta.url))],
|
||||
{ encoding:'utf8', env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'} })
|
||||
assert.equal(result.status,0,result.stderr || result.error?.message)
|
||||
assert.match(result.stderr,/Ran 6 tests/)
|
||||
})
|
||||
@@ -0,0 +1,445 @@
|
||||
"""Bounded remote TEST actions; injected by the JS executor, never invoked at import.
|
||||
|
||||
Only Order is recreated. Every Docker mutation is journaled before sending; an
|
||||
uncertain result blocks further mutations until explicit read-only reconciliation.
|
||||
"""
|
||||
import ast
|
||||
import base64
|
||||
import copy
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ORDER = 'firstleg-test-order'
|
||||
ENTRYPOINT = ['sh', '-c', 'exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher']
|
||||
PROPERTIES = {'IAM': 'baiyue.clients.iam.url', 'FINANCE': 'baiyue.clients.finance.url'}
|
||||
UPSTREAM_CONTAINERS = {'IAM': 'firstleg-test-iam', 'FINANCE': 'firstleg-test-finance'}
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *_args, **_kwargs): return None
|
||||
|
||||
|
||||
def http_open(request, timeout):
|
||||
return urllib.request.build_opener(NoRedirect()).open(request, timeout=timeout)
|
||||
|
||||
|
||||
def common_tools(source):
|
||||
"""Reuse only Docker read/config reconstruction primitives, never event/SQL functions."""
|
||||
tree = ast.parse(source)
|
||||
allowed = {'DockerConnection', 'docker', 'inspect', 'env_of', 'require_test', 'recreation', 'fingerprint'}
|
||||
nodes = [node for node in tree.body if isinstance(node, (ast.Import, ast.ImportFrom))
|
||||
or isinstance(node, (ast.FunctionDef, ast.ClassDef)) and node.name in allowed]
|
||||
namespace = {'__name__': 'order_dependency_primitives'}
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), '<reviewed-docker-primitives>', 'exec'), namespace)
|
||||
return {name: namespace[name] for name in allowed}
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
||||
|
||||
|
||||
def private_write(path, value):
|
||||
temporary = path.with_name(path.name + '.next')
|
||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, 'w') as stream:
|
||||
json.dump(value, stream); stream.flush(); os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
parent = os.open(path.parent, os.O_RDONLY)
|
||||
try: os.fsync(parent)
|
||||
finally: os.close(parent)
|
||||
|
||||
|
||||
def validate_scope(scope):
|
||||
if set(scope) != {'marker', 'customerId', 'orderIds', 'planSha256'} or not re.fullmatch(r'[A-Za-z0-9_-]{1,80}', scope['marker']):
|
||||
raise RuntimeError('INVALID_SCOPE')
|
||||
if not re.fullmatch(r'[1-9][0-9]{0,19}', scope['customerId']) or set(scope['orderIds']) != {'merchant', 'platform'}:
|
||||
raise RuntimeError('INVALID_SCOPE')
|
||||
if any(not re.fullmatch(r'[1-9][0-9]{0,19}', value) for value in scope['orderIds'].values()) or len(set(scope['orderIds'].values())) != 2:
|
||||
raise RuntimeError('TWO_DISTINCT_ORDERS_REQUIRED')
|
||||
if not re.fullmatch(r'[0-9a-f]{64}', scope['planSha256']): raise RuntimeError('INVALID_PLAN_HASH')
|
||||
|
||||
|
||||
class Runtime:
|
||||
def __init__(self, scope, tools, base=Path('/tmp')):
|
||||
validate_scope(scope)
|
||||
self.scope, self.tools = scope, tools
|
||||
self.directory = base / ('chongchong-order-dependency-' + scope['marker'])
|
||||
self.path = self.directory / 'state.json'
|
||||
self.lock = base / 'chongchong-order-dependency.lock'
|
||||
|
||||
def save(self, state): private_write(self.path, state)
|
||||
|
||||
def load(self):
|
||||
if self.directory.is_symlink() or self.directory.stat().st_uid != os.getuid() or self.directory.stat().st_mode & 0o077:
|
||||
raise RuntimeError('UNSAFE_STATE_DIRECTORY')
|
||||
if self.path.is_symlink() or self.path.stat().st_mode & 0o077: raise RuntimeError('UNSAFE_STATE_FILE')
|
||||
state = json.loads(self.path.read_text())
|
||||
if state['scope'] != self.scope: raise RuntimeError('SCOPE_CHANGED')
|
||||
if (state.get('mutation') or {}).get('step', '').endswith('_INTENT'):
|
||||
state['uncertain'] = True # Includes process death between HTTP completion and journal commit.
|
||||
return state
|
||||
|
||||
def container(self):
|
||||
value = self.tools['inspect'](ORDER)
|
||||
self.tools['require_test'](value, ORDER)
|
||||
if self.tools['env_of'](value).get('DEPLOY_ENV') != 'test': raise RuntimeError('NOT_TEST_ENV')
|
||||
return value
|
||||
|
||||
def control(self, state, mode=None, ttl=0):
|
||||
pid = state.get('proxyPid')
|
||||
if not isinstance(pid, int) or pid <= 1: raise RuntimeError('PROXY_PID_NOT_READY')
|
||||
process = Path('/proc') / str(pid)
|
||||
if str(self.directory / 'proxy.py').encode() not in (process / 'cmdline').read_bytes().split(b'\0'):
|
||||
raise RuntimeError('PROXY_PROCESS_OWNERSHIP_MISMATCH')
|
||||
socket_inodes = set()
|
||||
for descriptor in (process / 'fd').iterdir():
|
||||
try: target = os.readlink(descriptor)
|
||||
except FileNotFoundError: continue
|
||||
match = re.fullmatch(r'socket:\[(\d+)\]', target)
|
||||
if match: socket_inodes.add(match.group(1))
|
||||
owned_ports = set()
|
||||
for line in Path('/proc/net/tcp').read_text().splitlines()[1:]:
|
||||
values = line.split()
|
||||
if len(values) > 9 and values[3] == '0A' and values[9] in socket_inodes:
|
||||
owned_ports.add(int(values[1].split(':')[1], 16))
|
||||
if not {state['proxyPort'], state['controlPort']}.issubset(owned_ports): raise RuntimeError('PROXY_LISTENERS_NOT_OWNED')
|
||||
req = urllib.request.Request('http://127.0.0.1:' + str(state['controlPort']) + ('/mode' if mode else '/status'),
|
||||
None if mode is None else json.dumps({'mode': mode, 'ttl': ttl}).encode(),
|
||||
{'X-Order-Fault-Control': state['controlToken'], 'Content-Type': 'application/json'})
|
||||
with http_open(req, timeout=5) as response: return json.load(response)
|
||||
|
||||
def probes(self, state):
|
||||
"""Real authenticated reads, not manufactured business responses. No body/secret is returned."""
|
||||
customer = self.scope['customerId']; results = {}
|
||||
targets = {'IAM': '/internal/iam/tenants/' + customer + '/order-eligibility',
|
||||
'FINANCE': '/internal/finance/v1/wallet/accounts/' + customer}
|
||||
for dependency, path in targets.items():
|
||||
req = urllib.request.Request(state['upstreams'][dependency] + path,
|
||||
headers={'X-Internal-Service-Token': state['internalToken']})
|
||||
with http_open(req, timeout=8) as response:
|
||||
value = json.load(response)
|
||||
if response.status != 200 or str(value.get('code')) != '200': raise RuntimeError('UPSTREAM_PROBE_FAILED')
|
||||
data = value.get('data') or {}
|
||||
if dependency == 'IAM' and data.get('eligible') is not True: raise RuntimeError('CUSTOMER_NOT_ELIGIBLE')
|
||||
if dependency == 'FINANCE' and (data.get('actualBalance') is None or float(data['actualBalance']) < 0):
|
||||
raise RuntimeError('CUSTOMER_BALANCE_NOT_READY')
|
||||
results[dependency] = True
|
||||
return results
|
||||
|
||||
def config_fingerprint(self, container, routing):
|
||||
"""Read the two actual Nacos imports using explicitly reviewed v1 endpoints.
|
||||
No endpoint guessing/fallback and no Nacos write operation is supported.
|
||||
"""
|
||||
env = self.tools['env_of'](container); config = routing.get('nacos', {})
|
||||
base = urllib.parse.urlsplit(config.get('baseUrl', ''))
|
||||
configured = urllib.parse.urlsplit(env.get('NACOS_SERVER_ADDR', '') if '://' in env.get('NACOS_SERVER_ADDR', '')
|
||||
else 'http://' + env.get('NACOS_SERVER_ADDR', ''))
|
||||
allowed_hosts = {configured.hostname, '127.0.0.1', 'localhost'} | {
|
||||
network.get('Gateway') for network in container['NetworkSettings']['Networks'].values()}
|
||||
if (base.scheme not in ('http', 'https') or base.hostname not in allowed_hosts or base.port != configured.port
|
||||
or base.path not in ('', '/') or base.username or base.password or base.query or base.fragment
|
||||
or config.get('loginPath') not in ('/nacos/v1/auth/users/login', '/nacos/v1/auth/login')
|
||||
or config.get('configPath') != '/nacos/v1/cs/configs'):
|
||||
raise RuntimeError('EXPLICIT_NACOS_READ_ENDPOINTS_REQUIRED')
|
||||
if any(not env.get(key) for key in ['NACOS_USERNAME', 'NACOS_PASSWORD', 'NACOS_GROUP', 'NACOS_NAMESPACE']):
|
||||
raise RuntimeError('NACOS_READ_IDENTITY_UNAVAILABLE')
|
||||
origin = config['baseUrl'].rstrip('/')
|
||||
request = urllib.request.Request(origin + config['loginPath'], urllib.parse.urlencode({
|
||||
'username': env['NACOS_USERNAME'], 'password': env['NACOS_PASSWORD']}).encode(),
|
||||
{'Content-Type': 'application/x-www-form-urlencoded'})
|
||||
with http_open(request, timeout=8) as response: token = json.load(response).get('accessToken')
|
||||
if not isinstance(token, str) or not token: raise RuntimeError('NACOS_READ_LOGIN_FAILED')
|
||||
fingerprints = {}
|
||||
for data_id in ('baiyue-common.yml', 'order-service.yml'):
|
||||
query = urllib.parse.urlencode({'dataId': data_id, 'group': env['NACOS_GROUP'], 'tenant': env['NACOS_NAMESPACE'], 'accessToken': token})
|
||||
with http_open(origin + config['configPath'] + '?' + query, timeout=8) as response:
|
||||
if response.status != 200: raise RuntimeError('NACOS_SOURCE_READ_FAILED')
|
||||
content = response.read()
|
||||
if not content or content.lstrip().startswith((b'{', b'<')): raise RuntimeError('NACOS_SOURCE_NOT_YAML')
|
||||
fingerprints[data_id] = hashlib.sha256(content).hexdigest()
|
||||
return digest({'imports': fingerprints, 'namespace': env['NACOS_NAMESPACE'], 'group': env['NACOS_GROUP']})
|
||||
|
||||
def prepare(self, config):
|
||||
if self.directory.exists(): raise RuntimeError('STATE_EXISTS_USE_STATUS_OR_RESTORE')
|
||||
original = self.container()
|
||||
if original['Image'] != config.get('expectedImageId') or not re.fullmatch(r'sha256:[0-9a-f]{64}', original['Image']):
|
||||
raise RuntimeError('ORIGINAL_IMAGE_MISMATCH')
|
||||
if original['State'].get('Health', {}).get('Status') != 'healthy': raise RuntimeError('ORDER_NOT_HEALTHY')
|
||||
if original['Config']['Entrypoint'] != ENTRYPOINT or original['Config'].get('Cmd') not in (None, []):
|
||||
raise RuntimeError('UNREVIEWED_JAVA_ENTRYPOINT')
|
||||
if 'host.docker.internal:host-gateway' not in (original['HostConfig'].get('ExtraHosts') or []):
|
||||
raise RuntimeError('REVIEWED_PROXY_HOST_ROUTE_REQUIRED')
|
||||
env = self.tools['env_of'](original)
|
||||
token = env.get('INTERNAL_SERVICE_TOKEN', '')
|
||||
if len(token) < 16: raise RuntimeError('INTERNAL_TOKEN_UNAVAILABLE')
|
||||
routing = config.get('routing', {})
|
||||
if routing.get('configurationReviewed') is not True:
|
||||
raise RuntimeError('UPSTREAM_CONFIGURATION_REVIEW_REQUIRED')
|
||||
source_digest = self.config_fingerprint(original, routing)
|
||||
if routing.get('sourceSha256') != source_digest: raise RuntimeError('REVIEWED_NACOS_SOURCE_DIGEST_CHANGED')
|
||||
upstreams = routing.get('upstreams', {})
|
||||
if set(upstreams) != set(PROPERTIES): raise RuntimeError('EXPLICIT_UPSTREAMS_REQUIRED')
|
||||
for dependency, upstream in upstreams.items():
|
||||
url = urllib.parse.urlsplit(upstream)
|
||||
target = self.tools['inspect'](UPSTREAM_CONTAINERS[dependency]); self.tools['require_test'](target, UPSTREAM_CONTAINERS[dependency])
|
||||
target_env = self.tools['env_of'](target)
|
||||
ips = {network['IPAddress'] for name, network in target['NetworkSettings']['Networks'].items()
|
||||
if name in original['NetworkSettings']['Networks']}
|
||||
if (url.scheme != 'http' or url.username or url.password or url.path not in ('', '/') or url.query or url.fragment
|
||||
or url.hostname not in ips or str(url.port) != target_env.get('SERVER_PORT')
|
||||
or target_env.get('DEPLOY_ENV') != 'test' or target['State'].get('Health', {}).get('Status') != 'healthy'):
|
||||
raise RuntimeError('UPSTREAM_NOT_VERIFIED_TEST_CONTAINER')
|
||||
lock_fd = os.open(self.lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(lock_fd, 'w') as stream: stream.write(self.scope['marker']); stream.flush(); os.fsync(stream.fileno())
|
||||
self.directory.mkdir(mode=0o700)
|
||||
sockets = [socket.socket(), socket.socket()]
|
||||
for sock in sockets: sock.bind(('127.0.0.1', 0))
|
||||
proxy_port, control_port = [sock.getsockname()[1] for sock in sockets]
|
||||
for sock in sockets: sock.close()
|
||||
state = {'scope': self.scope, 'phase': 'PREPARED', 'originalId': original['Id'],
|
||||
'originalImageId': original['Image'], 'originalFingerprint': self.tools['fingerprint'](original),
|
||||
'originalBody': self.tools['recreation'](original, env, full=True), 'internalToken': token,
|
||||
'proxyPort': proxy_port, 'controlPort': control_port, 'controlToken': secrets.token_urlsafe(32),
|
||||
'upstreams': {k: v.rstrip('/') for k, v in upstreams.items()}, 'routingSourceSha256': source_digest, 'routing': routing,
|
||||
'orderIps': [x['IPAddress'] for x in original['NetworkSettings']['Networks'].values()], 'mutation': None, 'proxyLaunchIntent': False}
|
||||
self.save(state) # Recovery info exists before any process or container mutation.
|
||||
self.probes(state)
|
||||
proxy = self.directory / 'proxy.py'; proxy.write_bytes(base64.b64decode(config['proxySource'])); os.chmod(proxy, 0o600)
|
||||
private_write(self.directory / 'proxy-config.json', {k: state[k] for k in
|
||||
['internalToken', 'controlToken', 'proxyPort', 'controlPort', 'upstreams', 'orderIps']} | {'customerId': self.scope['customerId'],
|
||||
'requestIds': ['dep-' + self.scope['marker'] + '-' + dependency + '-' + role
|
||||
for dependency in ('IAM', 'FINANCE') for role in ('merchant', 'platform')]})
|
||||
state['phase'] = 'PROXY_START_INTENT'; state['proxyLaunchIntent'] = True; self.save(state)
|
||||
with open(self.directory / 'proxy-output.log', 'ab') as output:
|
||||
os.chmod(self.directory / 'proxy-output.log', 0o600)
|
||||
process = subprocess.Popen(['python3', str(proxy), '--config', str(self.directory / 'proxy-config.json')],
|
||||
stdout=output, stderr=output, start_new_session=True)
|
||||
state['proxyPid'] = process.pid; state['phase'] = 'PROXY_STARTED'; self.save(state)
|
||||
return self.summary(state)
|
||||
|
||||
def summary(self, state):
|
||||
return {'scope': self.scope, 'phase': state['phase'], 'originalImageId': state['originalImageId'],
|
||||
'originalFingerprint': state['originalFingerprint'], 'uncertain': bool(state.get('uncertain')),
|
||||
'mutationStep': (state.get('mutation') or {}).get('step'), 'restored': state.get('restored', False)}
|
||||
|
||||
def mutation(self, state, step, method, path, body=None):
|
||||
if state.get('uncertain'): raise RuntimeError('UNCERTAIN_MUTATION_REQUIRES_RECONCILE')
|
||||
state['mutation'] = {'step': step, 'path': path, 'bodySha256': digest(body), 'intentAt': time.time()}; self.save(state)
|
||||
try:
|
||||
result = self.tools['docker'](method, path, body)
|
||||
if step == 'CREATE_INTENT' and (not isinstance(result, dict) or not re.fullmatch(r'[A-Za-z0-9_-]{1,128}', result.get('Id', ''))):
|
||||
raise RuntimeError('INVALID_CREATE_ACK')
|
||||
except Exception:
|
||||
state['uncertain'] = True; self.save(state)
|
||||
raise RuntimeError('UNCERTAIN_DOCKER_MUTATION')
|
||||
state['mutation']['step'] = step.replace('_INTENT', '_DONE')
|
||||
if step == 'CREATE_INTENT': state['createdId'] = result['Id']
|
||||
self.save(state)
|
||||
return result
|
||||
|
||||
def replace(self, state, current, desired, purpose):
|
||||
if state.get('uncertain'): raise RuntimeError('UNCERTAIN_MUTATION_REQUIRES_RECONCILE')
|
||||
if self.container()['Id'] != current['Id']: raise RuntimeError('CONCURRENT_ORDER_CHANGE')
|
||||
image = self.tools['docker']('GET', '/images/' + urllib.parse.quote(desired['Image'], safe='') + '/json')
|
||||
if image['Id'] != state['originalImageId']: raise RuntimeError('IMAGE_TAG_MOVED_NO_RECREATE')
|
||||
state['purpose'] = purpose; state['desiredBody'] = desired; state['replacingId'] = current['Id']; self.save(state)
|
||||
if current['State']['Running']:
|
||||
self.mutation(state, 'STOP_INTENT', 'POST', '/containers/' + current['Id'] + '/stop?t=15')
|
||||
self.mutation(state, 'DELETE_INTENT', 'DELETE', '/containers/' + current['Id'] + '?v=false')
|
||||
created = self.mutation(state, 'CREATE_INTENT', 'POST', '/containers/create?name=' + ORDER, desired)
|
||||
self.mutation(state, 'START_INTENT', 'POST', '/containers/' + created['Id'] + '/start')
|
||||
state['currentId'] = created['Id']; state['phase'] = purpose; state['mutation'] = None; self.save(state)
|
||||
|
||||
def matches_injected(self, current, state):
|
||||
expected = copy.deepcopy(state['originalBody']['Entrypoint'])
|
||||
expected[2] += ''.join(' --' + prop + '=http://host.docker.internal:' + str(state['proxyPort']) + '/' + name.lower()
|
||||
for name, prop in PROPERTIES.items())
|
||||
restored_view = copy.deepcopy(current)
|
||||
restored_view['Config']['Entrypoint'] = state['originalBody']['Entrypoint']
|
||||
return (current['Image'] == state['originalImageId'] and current['Config']['Entrypoint'] == expected
|
||||
and self.tools['fingerprint'](restored_view) == state['originalFingerprint'])
|
||||
|
||||
def activate(self, state):
|
||||
if state['phase'] != 'PROXY_STARTED' or state.get('uncertain'): raise RuntimeError('ACTIVATE_NOT_RETRYABLE')
|
||||
if self.control(state)['mode'] != 'PASS': raise RuntimeError('PROXY_NOT_PASS')
|
||||
current = self.container()
|
||||
if current['Id'] != state['originalId'] or self.tools['fingerprint'](current) != state['originalFingerprint']:
|
||||
raise RuntimeError('ORIGINAL_CONFIGURATION_CHANGED')
|
||||
desired = copy.deepcopy(state['originalBody'])
|
||||
desired['Entrypoint'][2] += ''.join(' --' + prop + '=http://host.docker.internal:' + str(state['proxyPort']) + '/' + name.lower()
|
||||
for name, prop in PROPERTIES.items())
|
||||
self.replace(state, current, desired, 'INJECTED')
|
||||
state['injectedFingerprint'] = self.tools['fingerprint'](self.container()); self.save(state)
|
||||
return self.summary(state)
|
||||
|
||||
def restore(self, state):
|
||||
# PASS is attempted even if Docker state is ambiguous. Never stop the proxy here.
|
||||
try: self.control(state, 'PASS')
|
||||
except Exception:
|
||||
state['passUnconfirmed'] = True; self.save(state)
|
||||
if state.get('uncertain'): raise RuntimeError('UNCERTAIN_MUTATION_REQUIRES_RECONCILE')
|
||||
if state['phase'] == 'ABSENT_CONFIRMED':
|
||||
try: self.tools['inspect'](ORDER)
|
||||
except RuntimeError as error:
|
||||
if str(error) != 'DOCKER_404': raise
|
||||
else: raise RuntimeError('ORDER_REAPPEARED_NO_RECREATE')
|
||||
body = copy.deepcopy(state['originalBody'])
|
||||
image = self.tools['docker']('GET', '/images/' + urllib.parse.quote(body['Image'], safe='') + '/json')
|
||||
if image['Id'] != state['originalImageId']: raise RuntimeError('IMAGE_TAG_MOVED_NO_RECREATE')
|
||||
state['purpose'] = 'ORIGINAL_RESTORED'; state['desiredBody'] = body; self.save(state)
|
||||
created = self.mutation(state, 'CREATE_INTENT', 'POST', '/containers/create?name=' + ORDER, body)
|
||||
self.mutation(state, 'START_INTENT', 'POST', '/containers/' + created['Id'] + '/start')
|
||||
state['currentId'] = created['Id']; state['phase'] = 'ORIGINAL_RESTORED'; state['mutation'] = None; self.save(state)
|
||||
return self.summary(state)
|
||||
current = self.container()
|
||||
if self.tools['fingerprint'](current) == state['originalFingerprint'] and current['Image'] == state['originalImageId']:
|
||||
if not current['State']['Running']:
|
||||
state['purpose'] = 'ORIGINAL_RESTORED'; state['createdId'] = current['Id']; self.save(state)
|
||||
self.mutation(state, 'START_INTENT', 'POST', '/containers/' + current['Id'] + '/start')
|
||||
state['phase'] = 'ORIGINAL_RESTORED'; self.save(state); return self.summary(state)
|
||||
if current['Id'] != state.get('currentId') or not self.matches_injected(current, state):
|
||||
raise RuntimeError('UNRECOGNIZED_ORDER_CONFIGURATION')
|
||||
self.replace(state, current, copy.deepcopy(state['originalBody']), 'ORIGINAL_RESTORED')
|
||||
return self.summary(state)
|
||||
|
||||
def reconcile(self, state):
|
||||
# This action is intentionally read-only with respect to Docker. It never resumes deletion/creation.
|
||||
if not state.get('uncertain'): return self.summary(state)
|
||||
try: current = self.container()
|
||||
except RuntimeError as error:
|
||||
if str(error) != 'DOCKER_404' or (state.get('mutation') or {}).get('step') != 'DELETE_INTENT':
|
||||
raise RuntimeError('MANUAL_DOCKER_RECOVERY_REQUIRED_NO_BLIND_RECREATE')
|
||||
try: self.tools['inspect'](state['replacingId'])
|
||||
except RuntimeError as by_id:
|
||||
if str(by_id) != 'DOCKER_404': raise
|
||||
else: raise RuntimeError('ORIGINAL_ID_STILL_PRESENT')
|
||||
state['uncertain'] = False; state['phase'] = 'ABSENT_CONFIRMED'; state['mutation'] = None; self.save(state)
|
||||
return self.summary(state)
|
||||
step = (state.get('mutation') or {}).get('step')
|
||||
original = current['Image'] == state['originalImageId'] and self.tools['fingerprint'](current) == state['originalFingerprint']
|
||||
injected = self.matches_injected(current, state)
|
||||
desired_matches = injected if state.get('purpose') == 'INJECTED' else original
|
||||
if step == 'STOP_INTENT' and current['Id'] == state.get('replacingId') and not current['State']['Running'] and (original or injected):
|
||||
phase = 'ORIGINAL_RESTORED' if original else 'INJECTED'
|
||||
elif step == 'CREATE_INTENT' and current['Id'] != state.get('replacingId') and desired_matches:
|
||||
phase = state['purpose']
|
||||
elif step == 'START_INTENT' and current['Id'] == state.get('createdId') and current['State']['Running'] and desired_matches:
|
||||
phase = state['purpose']
|
||||
else:
|
||||
raise RuntimeError('MANUAL_DOCKER_RECOVERY_REQUIRED_NO_BLIND_RECREATE')
|
||||
state['uncertain'] = False; state['currentId'] = current['Id']; state['phase'] = phase; state['mutation'] = None
|
||||
if phase == 'INJECTED': state['injectedFingerprint'] = self.tools['fingerprint'](current)
|
||||
self.save(state); return self.summary(state)
|
||||
|
||||
def verify_restored(self, state):
|
||||
current = self.container()
|
||||
if state.get('uncertain') or current['Image'] != state['originalImageId'] or self.tools['fingerprint'](current) != state['originalFingerprint']:
|
||||
raise RuntimeError('RESTORED_CONFIG_OR_IMAGE_MISMATCH')
|
||||
if current['State'].get('Health', {}).get('Status') != 'healthy': raise RuntimeError('RESTORED_NOT_HEALTHY')
|
||||
if self.config_fingerprint(current, state['routing']) != state['routingSourceSha256']:
|
||||
raise RuntimeError('NACOS_ROUTE_CONFIGURATION_CHANGED')
|
||||
checks = self.probes(state)
|
||||
if checks != {'IAM': True, 'FINANCE': True}: raise RuntimeError('RESTORED_ROUTE_PROBE_INVALID')
|
||||
state['routeReadsVerified'] = checks; self.save(state)
|
||||
return {'healthy': True, 'originalFingerprint': state['originalFingerprint'], 'currentFingerprint': self.tools['fingerprint'](current),
|
||||
'originalImageId': state['originalImageId'], 'currentImageId': current['Image'], 'upstreamReads': checks,
|
||||
'nacosSourcesUnchanged': True}
|
||||
|
||||
def finish(self, state, business):
|
||||
self.verify_restored(state)
|
||||
if self.lock.is_symlink() or self.lock.read_text() != self.scope['marker']: raise RuntimeError('RUNTIME_LOCK_OWNER_CHANGED')
|
||||
if set(business or {}) != {'merchant', 'platform'} or any(
|
||||
business[role].get('orderId') != order_id or business[role].get('status') != 'SUBMITTED'
|
||||
or business[role].get('sameKeyReplayVerified') is not True for role, order_id in self.scope['orderIds'].items()):
|
||||
raise RuntimeError('RESTORED_ORDER_ROUTES_NOT_PROVED')
|
||||
pid = state.get('proxyPid')
|
||||
if not pid and state.get('proxyLaunchIntent'):
|
||||
pid_file = self.directory / 'proxy.pid'
|
||||
if pid_file.is_symlink() or not pid_file.exists() or not pid_file.read_text().isdigit(): raise RuntimeError('PROXY_PID_UNCERTAIN')
|
||||
pid = int(pid_file.read_text())
|
||||
command = Path('/proc/' + str(pid) + '/cmdline') if pid else None
|
||||
if command and command.exists():
|
||||
if str(self.directory / 'proxy.py').encode() not in command.read_bytes().split(b'\0'):
|
||||
raise RuntimeError('PROXY_PROCESS_OWNERSHIP_MISMATCH')
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
for _ in range(40):
|
||||
if not command.exists() or not command.read_bytes(): break
|
||||
time.sleep(0.1)
|
||||
else: raise RuntimeError('PROXY_NOT_STOPPED')
|
||||
state['restored'] = True; state['phase'] = 'FINISHED'
|
||||
# Recovery snapshots remain protected until completion; erase secrets only after verified restoration.
|
||||
for field in ['originalBody', 'desiredBody', 'internalToken', 'controlToken']: state.pop(field, None)
|
||||
self.save(state)
|
||||
config = self.directory / 'proxy-config.json'
|
||||
if config.exists(): config.unlink()
|
||||
self.lock.unlink()
|
||||
return {'restored': True, 'healthy': True, 'proxyStopped': True, 'originalImageId': state['originalImageId'],
|
||||
'originalFingerprint': state['originalFingerprint'], 'orderRoutesVerified': True}
|
||||
|
||||
|
||||
def execute(config):
|
||||
runtime = Runtime(config['scope'], common_tools(base64.b64decode(config['commonSource']).decode()))
|
||||
action = config['action']
|
||||
if action == 'preflight':
|
||||
current = runtime.container()
|
||||
return {'originalImageId': current['Image'], 'originalFingerprint': runtime.tools['fingerprint'](current),
|
||||
'sourceSha256': runtime.config_fingerprint(current, config['routing']), 'runtimeChanged': False}
|
||||
if action == 'prepare': return runtime.prepare(config)
|
||||
state = runtime.load()
|
||||
if action == 'status':
|
||||
result = runtime.summary(state)
|
||||
if not state.get('restored'):
|
||||
try: result['proxy'] = runtime.control(state)
|
||||
except Exception: result['proxy'] = {'unavailable': True}
|
||||
return result
|
||||
if state.get('restored'): raise RuntimeError('ALREADY_FINISHED_USE_STATUS')
|
||||
if action not in ('records', 'health') and (runtime.lock.is_symlink() or runtime.lock.read_text() != runtime.scope['marker']):
|
||||
raise RuntimeError('RUNTIME_LOCK_OWNER_CHANGED')
|
||||
if action == 'records':
|
||||
path = runtime.directory / 'proxy-evidence.jsonl'
|
||||
return {'records': [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else []}
|
||||
if action == 'activate': return runtime.activate(state)
|
||||
if action == 'mode':
|
||||
if state['phase'] != 'INJECTED' or state.get('uncertain'): raise RuntimeError('FAULT_WINDOW_NOT_READY')
|
||||
return runtime.control(state, config['mode'], config.get('ttl', 0))
|
||||
if action == 'health': return {'healthy': runtime.container()['State'].get('Health', {}).get('Status') == 'healthy'}
|
||||
if action == 'restore': return runtime.restore(state)
|
||||
if action == 'reconcile': return runtime.reconcile(state)
|
||||
if action == 'verify-restored': return runtime.verify_restored(state)
|
||||
if action == 'finish': return runtime.finish(state, config.get('businessRecovery'))
|
||||
raise RuntimeError('UNKNOWN_ACTION')
|
||||
|
||||
|
||||
def main(config):
|
||||
# A lost Jenkins response must not allow a second process to race the first Docker mutation.
|
||||
import fcntl
|
||||
validate_scope(config['scope'])
|
||||
path = Path('/tmp') / ('chongchong-order-dependency-' + config['scope']['marker'] + '.action.lock')
|
||||
if path.is_symlink(): raise RuntimeError('UNSAFE_ACTION_LOCK')
|
||||
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
|
||||
try:
|
||||
try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError: raise RuntimeError('RUNTIME_ACTION_IN_PROGRESS_USE_STATUS_LATER')
|
||||
return execute(config)
|
||||
finally: os.close(fd)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
result = main(json.loads(base64.b64decode(sys.argv[1])))
|
||||
print('ORDER_DEPENDENCY_JSON=' + json.dumps({'ok': True, 'data': result}))
|
||||
except Exception as error:
|
||||
message = str(error)
|
||||
print('ORDER_DEPENDENCY_JSON=' + json.dumps({'ok': False, 'error': message if re.fullmatch(r'[A-Z_0-9]{1,90}', message) else 'ORDER_DEPENDENCY_ACTION_FAILED'}))
|
||||
Reference in New Issue
Block a user