test(fba): verify signed delivery ACK loss and original event recovery

This commit is contained in:
2026-10-03 08:13:04 +08:00
parent ef1eef3278
commit 51bf4e5558
11 changed files with 499 additions and 57 deletions
+5 -1
View File
@@ -1,5 +1,9 @@
# 后端脚本管理与全量 HTTP 验收索引 # 后端脚本管理与全量 HTTP 验收索引
`test-delivery-fault-proof.mjs` 是签收回写故障的内部证据校验器,无CLI、网络或写入。按精确客户/派送/来源入仓/来源订单/根订单核对原事件及载荷摘要、13次投递与代理记录、唯一收件箱和人工重试审计,并比较重试前后的Firstleg与Order事实。首片签收仍有有效余量时允许根单继续等待;不把根单必须立即COMPLETED当成成功条件。
F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_CANCELLED` 只运行显式指定的一种顺序,省略仍串行执行三种;报告和成功范围仅覆盖所列模式。`--verify-delivery-event-recovery` 必须同时显式指定一种顺序,且环境为TEST;仅在该模式第一张真实FBA派送签收前开启定向ACK丢失,不能使用普通海外转运的签收替代国内回写。故障窗口会重建同镜像Lastleg并临时调整事件目标和退避配置,禁止与其他部署或共享环境写入并行;原事件DEAD恢复后必须验证原镜像/配置/网络身份、健康及代理退出。入口事先留存精确故障scope,finally兜底恢复,缺恢复证据或恢复校验失败一律失败。完整库存取证接入既有只读器,报告保留于该批次 `evidence/`,不能把某一模式或事件通过解读为全部反向事件完成。
`fleet-cross-customer-test-env.mjs` 是最小两客户共享车次的串行 TEST 专项入口,默认 `--plan` 零网络。显式运行参数为 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外收货仓ID> --destination-warehouse-id <FBA目的仓ID>`,凭据仅从显式 `node --env-file="<TEST配置>" --env-file="<Jenkins配置>"` 与 `FBA_CORE_JENKINS_AGENT` 提供。通过普通转运来货、PDA实收和客户确认创建两个新客户各一托一箱,分别预排并关联同车;一单未拣齐时验证全车拒绝及完整库存快照不变,再统一发车、原键重放、逐客户签收,最后正式结案并分别释放车辆/司机,核对结案前后库存零变化。完整库存由只读取证器获取,不写业务SQL,不部署或改共享配置。报告位于 `reports/fleet-cross-<新批次>/`,每次请求发送前记录意图;失败保留原现场,不自动重跑或续跑,已存在批次拒绝覆盖。finally停用本轮PDA并退出独立会话;业务样本保留。前置拒绝不代表事务中途回滚,后者另验。对应入口保护合同为 `fleet-cross-customer-test-env.test.mjs`;内部生成与验收函数见模块README。 `fleet-cross-customer-test-env.mjs` 是最小两客户共享车次的串行 TEST 专项入口,默认 `--plan` 零网络。显式运行参数为 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外收货仓ID> --destination-warehouse-id <FBA目的仓ID>`,凭据仅从显式 `node --env-file="<TEST配置>" --env-file="<Jenkins配置>"` 与 `FBA_CORE_JENKINS_AGENT` 提供。通过普通转运来货、PDA实收和客户确认创建两个新客户各一托一箱,分别预排并关联同车;一单未拣齐时验证全车拒绝及完整库存快照不变,再统一发车、原键重放、逐客户签收,最后正式结案并分别释放车辆/司机,核对结案前后库存零变化。完整库存由只读取证器获取,不写业务SQL,不部署或改共享配置。报告位于 `reports/fleet-cross-<新批次>/`,每次请求发送前记录意图;失败保留原现场,不自动重跑或续跑,已存在批次拒绝覆盖。finally停用本轮PDA并退出独立会话;业务样本保留。前置拒绝不代表事务中途回滚,后者另验。对应入口保护合同为 `fleet-cross-customer-test-env.test.mjs`;内部生成与验收函数见模块README。
头程订单提交使用首次原始详情的 `expectedVersion` 与原 `Idempotency-Key`。生产单票的 `SUBMIT_INTENT` 必须先持久化 `submitCommand` 再发送,显式续跑不得使用当前版本替换原命令,缺少原命令的提交意图及后续检查点停止并等待人工核对。源 Excel 造数同样在提交前保存订单条目的 `submitCommand`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。 头程订单提交使用首次原始详情的 `expectedVersion` 与原 `Idempotency-Key`。生产单票的 `SUBMIT_INTENT` 必须先持久化 `submitCommand` 再发送,显式续跑不得使用当前版本替换原命令,缺少原命令的提交意图及后续检查点停止并等待人工核对。源 Excel 造数同样在提交前保存订单条目的 `submitCommand`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。
@@ -22,7 +26,7 @@ Manifest 使用 `version:1, scope, revision, requirements:[{id,domain,level,chec
离线回归:`node --test scripts/acceptance-evidence-audit.test.mjs`。命令失败返回1,参数/文件错误返回2;报告写入调用者指定的新路径。 离线回归:`node --test scripts/acceptance-evidence-audit.test.mjs`。命令失败返回1,参数/文件错误返回2;报告写入调用者指定的新路径。
`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` 使用本机替身服务验证行为,不访问共享环境;工具离线通过不能替代真实重试验收。 `event-delivery-fault-proxy.py` 是 TEST 环境定向事件 ACK 丢失工具,不创建或重放事件。运行者显式传入 `--upstream` 完整消费者地址、`--event-type`,并只选 `--event-id`、`--loading-plan-id`、`--container-booking-id` 或 `--delivery-id`;派送选择器只读取正式 `payload.deliveryOrderId`,不接受 `deliveryId` 载荷别名;柜次选择精确匹配 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;环境执行仍需本轮写入授权。 `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;环境执行仍需本轮写入授权。
@@ -290,8 +290,9 @@ test('complete orchestrator calls actual source chain and blocks missing Outbox
for(const name of ['orders','inbound-measurement','phase3-customs','phase3-outbound-dispatch', for(const name of ['orders','inbound-measurement','phase3-customs','phase3-outbound-dispatch',
'phase3-carrier-schedule-queries'])assert.ok(source.includes(`'${name}'`)) 'phase3-carrier-schedule-queries'])assert.ok(source.includes(`'${name}'`))
for(const name of ['loadOneFragment','verifyPalletLabels','awaitProducerConsumption', for(const name of ['loadOneFragment','verifyPalletLabels','awaitProducerConsumption',
'convertReceivingContainers','prepareTerminalDrayage','runReceiving','pickAll','saveReceiptAndComplete']) 'convertReceivingContainers','prepareTerminalDrayage','runReceiving','pickAll','withDeliveryEventRecovery'])
assert.match(source,new RegExp(`await ${name}\\(`)) assert.match(source,new RegExp(`await ${name}\\(`))
assert.match(source,/\(\) => saveReceiptAndComplete\(context, deliveries\[0\], customerId, index\)/)
assert.match(source,/rootOrderId,\s*sourceOrderIds:\[rootOrderId,childOrderId\]/) assert.match(source,/rootOrderId,\s*sourceOrderIds:\[rootOrderId,childOrderId\]/)
assert.match(source,/HTTP_VERIFIED_OUTBOX_PENDING/) assert.match(source,/HTTP_VERIFIED_OUTBOX_PENDING/)
assert.match(source,/finally \{ preserveReceivingEvidence\(proof,scope\); proof.currentStage = 'PDA_IDENTITY_CLEANUP'; await disablePicker/) assert.match(source,/finally \{ preserveReceivingEvidence\(proof,scope\); proof.currentStage = 'PDA_IDENTITY_CLEANUP'; await disablePicker/)
@@ -5,6 +5,8 @@ import { stableLoadedCartonMarks } from './phase3-loading.mjs'
import { awaitProducerConsumption, convertReceivingContainers, runReceiving, resolveSharedWarehouse, import { awaitProducerConsumption, convertReceivingContainers, runReceiving, resolveSharedWarehouse,
resolvePickerAccess, createPicker, disablePicker } from './phase4-overseas-pda.mjs' resolvePickerAccess, createPicker, disablePicker } from './phase4-overseas-pda.mjs'
import { pickAll, verifyDepartureReadiness, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs' import { pickAll, verifyDepartureReadiness, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs'
import { isDeepStrictEqual } from 'node:util'
import { assertTestDeliveryFaultProof } from '../test-delivery-fault-proof.mjs'
import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs' import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs'
import { prepareTaxCustoms, verifyTaxDocumentsAfterCompletion } from './phase5-root-tax-customs.mjs' import { prepareTaxCustoms, verifyTaxDocumentsAfterCompletion } from './phase5-root-tax-customs.mjs'
import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs' import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs'
@@ -432,7 +434,10 @@ async function sendFragment(context, producer, picker, merchantToken, index) {
query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}/status`,{customerId}), query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}/status`,{customerId}),
{status:'DELIVERING',expectedVersion:Number(beforeDelivery.order.version),remark:context.marker}) {status:'DELIVERING',expectedVersion:Number(beforeDelivery.order.version),remark:context.marker})
check(context,'F40 派送处于配送中后保存签收',delivering.deliveryStatus === 'DELIVERING',`delivery=${deliveries[0].deliveryNo}`) check(context,'F40 派送处于配送中后保存签收',delivering.deliveryStatus === 'DELIVERING',`delivery=${deliveries[0].deliveryNo}`)
const completed = await saveReceiptAndComplete(context, deliveries[0], customerId, index) const completed = await withDeliveryEventRecovery(context, {
marker: context.context.f40BaseMarker, customerId: id(customerId), deliveryId: id(beforeDelivery.order.id),
sourceInboundId: id(producer.sourceInboundId), sourceOrderId: id(producer.sourceOrderId), rootOrderId: id(producer.rootOrderId),
}, id(picker.warehouseId), index, () => saveReceiptAndComplete(context, deliveries[0], customerId, index))
check(context, 'F40 实际签收资料驱动派送完成', completed?.order?.status === 'DELIVERED', `delivery=${deliveries[0].deliveryNo}`) check(context, 'F40 实际签收资料驱动派送完成', completed?.order?.status === 'DELIVERED', `delivery=${deliveries[0].deliveryNo}`)
check(context,'F40 签收完成记录真实签收时间', Boolean(completed.order.signedAt), `delivery=${deliveries[0].deliveryNo}`) check(context,'F40 签收完成记录真实签收时间', Boolean(completed.order.signedAt), `delivery=${deliveries[0].deliveryNo}`)
const tripClosure = await finishFleetTrip(context, MODULE, tripProof) const tripClosure = await finishFleetTrip(context, MODULE, tripProof)
@@ -440,6 +445,51 @@ async function sendFragment(context, producer, picker, merchantToken, index) {
receiptVersion: completed.order.version, signedAt: completed.order.signedAt, receiptSnapshot:completed, tripNo: tripProof.tripNo, tripClosure } receiptVersion: completed.order.version, signedAt: completed.order.signedAt, receiptSnapshot:completed, tripNo: tripProof.tripNo, tripClosure }
} }
/** Only the first actual FBA signature is faulted; caller performs the normal receipt command. */
export async function withDeliveryEventRecovery(context, faultScope, warehouseId, index, sign) {
const executor = context.context.f40DeliveryEventFaultExecutor
if (!executor || index !== 0) return sign()
const proof = context.createdResources.phase5RootFulfillment
if (proof.deliveryEventRecovery) throw Error('F40 delivery recovery already attempted')
const reader = context.context.f40ReadOnlyInventoryEvidence
if (typeof reader !== 'function') throw Error('F40 complete inventory evidence reader required')
const record = proof.deliveryEventRecovery = { scope: faultScope, status: 'PREPARING', restored: false }
const inventoryScope = { marker: faultScope.marker, customerId: faultScope.customerId, warehouseId }
try {
record.inventoryBefore = await reader(inventoryScope)
record.preparationAttempted = true
await executor.prepare(faultScope)
record.status = 'SIGNING'
const result = await sign()
record.status = 'RECOVERING'
record.proof = await executor.readProof(faultScope)
assertTestDeliveryFaultProof(record.proof, faultScope)
if (record.proof.restored !== true) throw Error('F40 delivery producer restoration missing')
record.inventoryAfter = await reader(inventoryScope)
if (record.inventoryBefore?.scope?.coverage !== 'CUSTOMER_WAREHOUSE_COMPLETE'
|| record.inventoryBefore.scope.customerId !== faultScope.customerId
|| record.inventoryBefore.scope.warehouseId !== warehouseId
|| !['balances','positions','ledger'].every(key => Array.isArray(record.inventoryBefore[key]))
|| !isDeepStrictEqual(record.inventoryBefore, record.inventoryAfter)) throw Error('F40 full inventory drift during delivery recovery')
record.inventoryUnchanged = true
record.status = 'VERIFIED'
return result
} catch (error) {
record.status = 'FAILED'
throw error
} finally {
if (record.preparationAttempted) try {
record.restoration = await executor.cleanup()
record.restored = record.restoration?.restored === true && record.restoration?.healthy === true && record.restoration?.proxyStopped === true
if (!record.restored) throw Error('F40 delivery restoration incomplete')
} catch (error) {
record.status = 'RESTORATION_FAILED'
record.restored = false
throw error
}
}
}
/** Explicit TEST orchestrator, called by the parent acceptance context; never synthesizes an event. */ /** Explicit TEST orchestrator, called by the parent acceptance context; never synthesizes an event. */
export async function run(context) { export async function run(context) {
try { return await runRootFulfillment(context) } try { return await runRootFulfillment(context) }
+8 -2
View File
@@ -32,11 +32,12 @@ def encoded(value):
class State: class State:
def __init__(self, event_type, event_id, loading_plan_id, log_path=None, container_booking_id=None): def __init__(self, event_type, event_id, loading_plan_id, log_path=None, container_booking_id=None, delivery_id=None):
self.event_type = event_type self.event_type = event_type
self.selector_id = event_id self.selector_id = event_id
self.plan_id = loading_plan_id self.plan_id = loading_plan_id
self.booking_id = container_booking_id self.booking_id = container_booking_id
self.delivery_id = delivery_id
self.log_path = log_path self.log_path = log_path
self.lock = threading.Lock() self.lock = threading.Lock()
self.mode = "PASS" self.mode = "PASS"
@@ -70,6 +71,8 @@ class State:
selected = event_id == self.selector_id selected = event_id == self.selector_id
elif self.booking_id: elif self.booking_id:
selected = isinstance(payload, dict) and str(payload.get("sourceContainerBookingId")) == self.booking_id selected = isinstance(payload, dict) and str(payload.get("sourceContainerBookingId")) == self.booking_id
elif self.delivery_id:
selected = isinstance(payload, dict) and str(payload.get("deliveryOrderId")) == self.delivery_id
else: else:
selected = isinstance(payload, dict) and str(payload.get("loadingPlanId")) == self.plan_id selected = isinstance(payload, dict) and str(payload.get("loadingPlanId")) == self.plan_id
qualifies = event.get("eventType") == self.event_type and selected qualifies = event.get("eventType") == self.event_type and selected
@@ -260,6 +263,7 @@ def main():
selector.add_argument("--event-id") selector.add_argument("--event-id")
selector.add_argument("--loading-plan-id") selector.add_argument("--loading-plan-id")
selector.add_argument("--container-booking-id") selector.add_argument("--container-booking-id")
selector.add_argument("--delivery-id")
parser.add_argument("--bind", default="127.0.0.1") parser.add_argument("--bind", default="127.0.0.1")
parser.add_argument("--port", type=int, default=0) parser.add_argument("--port", type=int, default=0)
parser.add_argument("--control-port", type=int, default=0) parser.add_argument("--control-port", type=int, default=0)
@@ -278,9 +282,11 @@ def main():
parser.error("loading-plan-id must be a positive decimal identifier") parser.error("loading-plan-id must be a positive decimal identifier")
if args.container_booking_id and not re.fullmatch(r"[1-9][0-9]{0,19}", args.container_booking_id): if args.container_booking_id and not re.fullmatch(r"[1-9][0-9]{0,19}", args.container_booking_id):
parser.error("container-booking-id must be a positive decimal identifier") parser.error("container-booking-id must be a positive decimal identifier")
if args.delivery_id and not re.fullmatch(r"[1-9][0-9]{0,19}", args.delivery_id):
parser.error("delivery-id must be a positive decimal identifier")
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9]{0,79}", args.event_type) or not 0 < args.timeout <= 120: if not re.fullmatch(r"[A-Za-z][A-Za-z0-9]{0,79}", args.event_type) or not 0 < args.timeout <= 120:
parser.error("invalid event type or timeout") parser.error("invalid event type or timeout")
state = State(args.event_type, args.event_id, args.loading_plan_id, args.log, args.container_booking_id) state = State(args.event_type, args.event_id, args.loading_plan_id, args.log, args.container_booking_id, args.delivery_id)
data = ThreadingHTTPServer((args.bind, args.port), data_handler(state, args.upstream, args.timeout)) data = ThreadingHTTPServer((args.bind, args.port), data_handler(state, args.upstream, args.timeout))
control = ThreadingHTTPServer(("127.0.0.1", args.control_port), control_handler(state, token)) control = ThreadingHTTPServer(("127.0.0.1", args.control_port), control_handler(state, token))
for server in (data, control): for server in (data, control):
+43 -1
View File
@@ -3,7 +3,7 @@ import { spawn, spawnSync } from 'node:child_process'
import { createServer } from 'node:http' import { createServer } from 'node:http'
import { mkdtemp, readFile, rm } from 'node:fs/promises' import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join, dirname, basename, resolve } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import test from 'node:test' import test from 'node:test'
@@ -78,6 +78,8 @@ async function fixture(selector = ['--loading-plan-id', '2100000000000000001'],
child.kill(); await exited child.kill(); await exited
} }
await close(consumer) await close(consumer)
assert.equal(dirname(resolve(directory)), resolve(tmpdir()))
assert.ok(basename(directory).startsWith('event-fault-offline-'))
await rm(directory, { recursive: true }) await rm(directory, { recursive: true })
}, },
} }
@@ -218,3 +220,43 @@ test('booking selector rejects malformed identities and mixed selection before s
assert.ok(!result.stdout.includes('"ready"')) assert.ok(!result.stdout.includes('"ready"'))
} }
}) })
test('delivery selector isolates the first exact delivery event and preserves its ACK-loss recovery', async t => {
const deliveryId = '2100000000000000088'
const proxy = await fixture(['--delivery-id', deliveryId], 'OverseasDeliveryCompleted')
t.after(() => proxy.stop())
await proxy.control('/mode', { mode: 'FAULT' })
const signed = { ...event(), eventType: 'OverseasDeliveryCompleted',
payload: { deliveryOrderId: deliveryId, sourceInboundId: '2100000000000000099' } }
for (const unrelated of [
{ ...signed, eventId: otherId, payload: { deliveryOrderId: '2100000000000000089' } },
{ ...signed, eventId: otherId, payload: { deliveryId } },
{ ...signed, eventId: otherId, payload: { loadingPlanId: deliveryId } },
{ ...signed, eventId: otherId, eventType: 'OverseasContainerUnloaded' },
]) assert.equal((await proxy.send(unrelated)).status, 200)
assert.equal((await proxy.send(signed)).status, 503)
assert.equal((await proxy.send(signed)).status, 503)
assert.equal((await proxy.send({ ...signed, eventId: samePlanOtherId })).status, 200)
const calls = proxy.calls.length
assert.equal((await proxy.send({ ...signed, payload: { deliveryOrderId: '2100000000000000089' } })).status, 409)
assert.equal(proxy.calls.length, calls)
await proxy.control('/mode', { mode: 'PASS' })
const recovered = await proxy.send(signed)
assert.equal(recovered.status, 200)
assert.equal(JSON.parse(recovered.raw).data.outcome, 'DUPLICATE')
const status = await (await proxy.control('/status')).json()
assert.equal(status.eventId, targetId)
assert.deepEqual(status.records.filter(row => row.errorCode !== 'TARGET_EVENT_HASH_CONFLICT').map(row => row.outcome), ['APPLIED', 'DUPLICATE', 'DUPLICATE'])
assert.ok(!(await readFile(proxy.log, 'utf8')).includes('sourceInboundId'))
})
test('delivery selection rejects malformed IDs or a second selector before opening a listener', () => {
for (const args of [['--delivery-id', '0'], ['--delivery-id', '../88'], ['--delivery-id', '88', '--event-id', targetId],
['--delivery-id', '88', '--container-booking-id', '66'], ['--delivery-id', '88', '--loading-plan-id', '77']]) {
const result = spawnSync(process.env.PYTHON || 'python', [script, '--upstream', 'http://127.0.0.1:1/internal/events',
'--event-type', 'OverseasDeliveryCompleted', ...args], { encoding: 'utf8', timeout: 5000,
env: { ...process.env, EVENT_FAULT_CONTROL_TOKEN: controlToken } })
assert.equal(result.status, 2)
assert.ok(!result.stdout.includes('"ready"'))
}
})
+52 -14
View File
@@ -15,18 +15,26 @@ export function parseCoreArguments(argv) {
const values={},flags=new Set() const values={},flags=new Set()
for(let i=0;i<argv.length;i++) { for(let i=0;i<argv.length;i++) {
const arg=argv[i] const arg=argv[i]
if(['--plan','--execute'].includes(arg)) {if(flags.has(arg))throw Error('Duplicate execution flag');flags.add(arg);continue} if(['--plan','--execute','--verify-delivery-event-recovery'].includes(arg)) {if(flags.has(arg))throw Error('Duplicate execution flag');flags.add(arg);continue}
if(!['--environment','--run-id','--output-dir','--tax-mode'].includes(arg)||values[arg]!==undefined||!argv[i+1]||argv[i+1].startsWith('--'))throw Error('Invalid FBA core argument') if(!['--environment','--run-id','--output-dir','--tax-mode','--signature-mode'].includes(arg)||values[arg]!==undefined||!argv[i+1]||argv[i+1].startsWith('--'))throw Error('Invalid FBA core argument')
values[arg]=argv[++i] values[arg]=argv[++i]
} }
if(flags.size>1)throw Error('Choose plan or execute') if(flags.has('--plan')&&flags.has('--execute'))throw Error('Choose plan or execute')
if(values['--environment'] && values['--environment']!=='TEST')throw Error('Only explicit TEST is supported') if(values['--environment'] && values['--environment']!=='TEST')throw Error('Only explicit TEST is supported')
const execute=flags.has('--execute'),runId=values['--run-id']||(!execute?'plan':null) const execute=flags.has('--execute'),runId=values['--run-id']||(!execute?'plan':null)
if(!runId||!/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(runId))throw Error('Fresh run-id is required') if(!runId||!/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(runId))throw Error('Fresh run-id is required')
if(execute&&values['--environment']!=='TEST')throw Error('Execution requires --environment TEST') if(execute&&values['--environment']!=='TEST')throw Error('Execution requires --environment TEST')
const taxMode=values['--tax-mode']||'BUY_DECLARATION' const taxMode=values['--tax-mode']||'BUY_DECLARATION'
if(!F40_TAX_MODES.includes(taxMode))throw Error('Unsupported F40 tax mode') if(!F40_TAX_MODES.includes(taxMode))throw Error('Unsupported F40 tax mode')
return {execute,environment:'TEST',runId,taxMode,outputDir:values['--output-dir']||`reports/fba-core-${runId}`} const signatureMode=values['--signature-mode']
if(signatureMode&&!F40_MODES.includes(signatureMode))throw Error('Unsupported F40 signature mode')
const verifyDeliveryEventRecovery=flags.has('--verify-delivery-event-recovery')
if(verifyDeliveryEventRecovery&&!signatureMode)throw Error('Delivery fault requires one explicit signature mode')
return {execute,environment:'TEST',runId,taxMode,signatureMode,verifyDeliveryEventRecovery,outputDir:values['--output-dir']||`reports/fba-core-${runId}`}
}
function selectedModes(config) {
if(config.signatureMode&&!F40_MODES.includes(config.signatureMode))throw Error('Unsupported F40 signature mode')
return config.signatureMode?[config.signatureMode]:F40_MODES
} }
function taxScope(taxMode) { function taxScope(taxMode) {
if(!F40_TAX_MODES.includes(taxMode))throw Error('Unsupported F40 tax mode') if(!F40_TAX_MODES.includes(taxMode))throw Error('Unsupported F40 tax mode')
@@ -36,13 +44,16 @@ function taxScope(taxMode) {
} }
export function executionPlan(config) { export function executionPlan(config) {
return {environment:'TEST',execution:config.execute?'EXECUTE':'PLAN',runId:config.runId,outputDir:config.outputDir, return {environment:'TEST',execution:config.execute?'EXECUTE':'PLAN',runId:config.runId,outputDir:config.outputDir,
taxMode:config.taxMode,scope:taxScope(config.taxMode), taxMode:config.taxMode,scope:taxScope(config.taxMode),verifyDeliveryEventRecovery:config.verifyDeliveryEventRecovery===true,
modes:F40_MODES,module:'phase5-root-fulfillment',requiredEnvironmentKeys:REQUIRED_ENV_KEYS, modes:selectedModes(config),module:'phase5-root-fulfillment',requiredEnvironmentKeys:REQUIRED_ENV_KEYS,
optionalEnvironmentKeys:['CHONGCHONG_MERCHANT_USERNAME','CHONGCHONG_MERCHANT_PASSWORD'], optionalEnvironmentKeys:['CHONGCHONG_MERCHANT_USERNAME','CHONGCHONG_MERCHANT_PASSWORD'],
behavior:'Serial fresh fixtures; stop on first failed mode. HTTP creates customers/orders/files and uses shared PDA/pallet resources. SQL is read-only; no deploy or database mutation.', behavior:config.verifyDeliveryEventRecovery
reports:['manifest.json','summary.json',...F40_MODES.map(mode=>`${mode.toLowerCase()}.json`),'evidence/*.json'], ?'One explicit mode, first FBA receipt only: scoped ACK loss, original DEAD retry and verified Lastleg image/configuration restoration. HTTP creates fresh fixtures; SQL is read-only. Do not overlap deployments or other shared-environment writers.'
:'Serial fresh fixtures; stop on first failed mode. HTTP creates customers/orders/files and uses shared PDA/pallet resources. SQL is read-only; no deploy or database mutation.',
reports:['manifest.json','summary.json',...selectedModes(config).map(mode=>`${mode.toLowerCase()}.json`),'evidence/*.json'],
retention:'Exclusive run directory and evidence files. Interrupted lock requires operator review; no automatic cleanup or resume.', retention:'Exclusive run directory and evidence files. Interrupted lock requires operator review; no automatic cleanup or resume.',
scopeLimit:'F40 three modes and receiving/completion evidence; not all F01-F40, UI acceptance or reverse event replay.'} scopeLimit:config.verifyDeliveryEventRecovery?'Only the selected first delivery event and listed F40 mode; not all reverse events, fee-void recovery, or UI acceptance.'
:'Only the listed F40 modes and receiving/completion evidence; not all F01-F40, UI acceptance or reverse event replay.'}
} }
export function validateCoreEnvironment(env,root=repositoryRoot) { export function validateCoreEnvironment(env,root=repositoryRoot) {
if(basename(root)!=='baiyue-firstleg'||basename(dirname(root))!=='chongchong-global')throw Error('TEST workspace required') if(basename(root)!=='baiyue-firstleg'||basename(dirname(root))!=='chongchong-global')throw Error('TEST workspace required')
@@ -84,9 +95,11 @@ function createAcceptanceContext(mode,config,env,gateway) {
markerNamespace:createHash('sha256').update(config.runId+':'+mode).digest('hex').slice(0,12)}) markerNamespace:createHash('sha256').update(config.runId+':'+mode).digest('hex').slice(0,12)})
return new AcceptanceRun(options) return new AcceptanceRun(options)
} }
export async function runFbaCore(config,{env=process.env,readers,createContext=createAcceptanceContext,outputRoot=repositoryRoot}={}) { export async function runFbaCore(config,{env=process.env,readers,createContext=createAcceptanceContext,outputRoot=repositoryRoot,faultFactory}={}) {
const scopeDescription=taxScope(config.taxMode) const scopeDescription=taxScope(config.taxMode)
const modes=selectedModes(config)
const gateway=validateCoreEnvironment(env) const gateway=validateCoreEnvironment(env)
if(config.verifyDeliveryEventRecovery&&(env.CHONGCHONG_ENV!=='test'||!config.signatureMode))throw Error('Delivery fault requires explicit TEST and one signature mode')
const output=resolve(outputRoot,config.outputDir),reports=resolve(outputRoot,'reports'),rel=relative(reports,output) const output=resolve(outputRoot,config.outputDir),reports=resolve(outputRoot,'reports'),rel=relative(reports,output)
if(!config.execute||config.environment!=='TEST'||!rel||rel.startsWith('..')||isAbsolute(rel))throw Error('Fresh report directory inside reports required') if(!config.execute||config.environment!=='TEST'||!rel||rel.startsWith('..')||isAbsolute(rel))throw Error('Fresh report directory inside reports required')
mkdirSync(reports,{recursive:true}) mkdirSync(reports,{recursive:true})
@@ -94,7 +107,7 @@ export async function runFbaCore(config,{env=process.env,readers,createContext=c
const lockFd=openSync(lock,'wx') const lockFd=openSync(lock,'wx')
const secrets=[...REQUIRED_ENV_KEYS,'CHONGCHONG_MERCHANT_USERNAME','CHONGCHONG_MERCHANT_PASSWORD'] const secrets=[...REQUIRED_ENV_KEYS,'CHONGCHONG_MERCHANT_USERNAME','CHONGCHONG_MERCHANT_PASSWORD']
.filter(key=>/PASSWORD|TOKEN|USERNAME|JENKINS_USER/.test(key)).map(key=>env[key]) .filter(key=>/PASSWORD|TOKEN|USERNAME|JENKINS_USER/.test(key)).map(key=>env[key])
const summary={runId:config.runId,environment:'TEST',taxMode:config.taxMode,startedAt:new Date().toISOString(),success:false,modes:[],scope:scopeDescription} const summary={runId:config.runId,environment:'TEST',taxMode:config.taxMode,verifyDeliveryEventRecovery:config.verifyDeliveryEventRecovery===true,startedAt:new Date().toISOString(),success:false,modes:[],scope:scopeDescription}
let initialized=false let initialized=false
const merchantBefore={username:process.env.MERCHANT_TEST_USERNAME,password:process.env.MERCHANT_TEST_PASSWORD} const merchantBefore={username:process.env.MERCHANT_TEST_USERNAME,password:process.env.MERCHANT_TEST_PASSWORD}
try { try {
@@ -105,7 +118,7 @@ export async function runFbaCore(config,{env=process.env,readers,createContext=c
save('manifest.json',executionPlan(config)) save('manifest.json',executionPlan(config))
readers ||= createTestEvidenceReaders(env) readers ||= createTestEvidenceReaders(env)
await readers.preflight() await readers.preflight()
for(const mode of F40_MODES) { for(const mode of modes) {
const context=createContext(mode,config,env,gateway) const context=createContext(mode,config,env,gateway)
let sequence=0 let sequence=0
const evidence=async(kind,scope,read)=>{ const evidence=async(kind,scope,read)=>{
@@ -118,17 +131,42 @@ export async function runFbaCore(config,{env=process.env,readers,createContext=c
context.context.f40TaxMode=config.taxMode context.context.f40TaxMode=config.taxMode
context.context.f40ReadOnlyOutboxEvidence=scope=>evidence('completion',scope,readers.readCompletion) context.context.f40ReadOnlyOutboxEvidence=scope=>evidence('completion',scope,readers.readCompletion)
context.context.phase4ReceivingEvidenceReader=scope=>evidence('receiving',scope,readers.readReceiving) context.context.phase4ReceivingEvidenceReader=scope=>evidence('receiving',scope,readers.readReceiving)
let fault, faultPreparationAttempted=false
if(config.verifyDeliveryEventRecovery) {
if(typeof readers.readFleetInventory!=='function')throw Error('Complete raw inventory reader required for delivery recovery')
const factory=faultFactory||(await import('./test-event-fault-executor.mjs')).createTestDeliveryFaultExecutor
const executor=factory(env)
fault={...executor,prepare:async scope=>{
save(`evidence/${mode.toLowerCase()}-delivery-scope.json`,{scope,stage:'PREPARE_INTENT'})
faultPreparationAttempted=true;return executor.prepare(scope)
}}
context.context.f40DeliveryEventFaultExecutor=fault
context.context.f40ReadOnlyInventoryEvidence=scope=>evidence('inventory',scope,readers.readFleetInventory)
}
let report let report
try {report=await context.run()} try {report=await context.run()}
catch (error) {report={success:false,fatal:'FBA_CORE_MODE_EXCEPTION',diagnostic:safeFailure(error),summary:{failed:1},createdResources:context.createdResources}} catch (error) {report={success:false,fatal:'FBA_CORE_MODE_EXCEPTION',diagnostic:safeFailure(error),summary:{failed:1},createdResources:context.createdResources}}
finally {secrets.push(context.token,context.pdaToken,context.refreshToken,context.context?.merchantQualificationFixture?.password)} finally {
secrets.push(context.token,context.pdaToken,context.refreshToken,context.context?.merchantQualificationFixture?.password)
if(fault) {
try {
const restoration=await fault.cleanup()
save(`evidence/${mode.toLowerCase()}-delivery-restoration.json`,{attempted:faultPreparationAttempted,restoration:restoration??null})
if(faultPreparationAttempted&&(restoration?.restored!==true||restoration.healthy!==true||restoration.proxyStopped!==true))throw Error('Delivery restoration unproven')
} catch(error) {report={...report,success:false,deliveryRestorationFailed:true,restorationDiagnostic:safeFailure(error)}}
}
}
if(config.verifyDeliveryEventRecovery) {
const proof=report?.createdResources?.phase5RootFulfillment?.deliveryEventRecovery
if(proof?.status!=='VERIFIED')report={...report,success:false,deliveryRecoveryUnproven:true}
}
report={...report,regressionScope:{taxMode:config.taxMode,signatureOrder:mode,description:scopeDescription}} report={...report,regressionScope:{taxMode:config.taxMode,signatureOrder:mode,description:scopeDescription}}
const name=`${mode.toLowerCase()}.json`;save(name,report) const name=`${mode.toLowerCase()}.json`;save(name,report)
const passed=modePassed(report) const passed=modePassed(report)
summary.modes.push({mode,taxMode:config.taxMode,report:name,passed,summary:report.summary,evidenceFiles:sequence}) summary.modes.push({mode,taxMode:config.taxMode,report:name,passed,summary:report.summary,evidenceFiles:sequence})
if(!passed)break if(!passed)break
} }
summary.success=summary.modes.length===F40_MODES.length&&summary.modes.every(mode=>mode.passed) summary.success=summary.modes.length===modes.length&&summary.modes.every(mode=>mode.passed)
} catch (error) {summary.failure='FBA_CORE_PREFLIGHT_OR_RUN_FAILED';summary.diagnostic=safeFailure(error)} } catch (error) {summary.failure='FBA_CORE_PREFLIGHT_OR_RUN_FAILED';summary.diagnostic=safeFailure(error)}
finally { finally {
summary.finishedAt=new Date().toISOString() summary.finishedAt=new Date().toISOString()
@@ -33,6 +33,16 @@ test('tax mode is explicit, defaults to BUY and rejects aliases, duplicate and u
for(const args of [['--tax-mode','REFUND'],['--tax-mode','tax_refund'],['--tax-mode',''],['--taxMode','TAX_REFUND'], for(const args of [['--tax-mode','REFUND'],['--tax-mode','tax_refund'],['--tax-mode',''],['--taxMode','TAX_REFUND'],
['--tax-mode','TAX_REFUND','--tax-mode','BUY_DECLARATION']])assert.throws(()=>parseCoreArguments(args)) ['--tax-mode','TAX_REFUND','--tax-mode','BUY_DECLARATION']])assert.throws(()=>parseCoreArguments(args))
}) })
test('explicit single signature mode runs only that mode and reports the narrowed scope', async()=>{
const cfg=parseCoreArguments(['--execute','--environment','TEST','--run-id','single-mode','--signature-mode','ROOT_FIRST'])
assert.deepEqual(executionPlan(cfg).modes,['ROOT_FIRST'])
assert.doesNotMatch(executionPlan(cfg).reports.join(','),/child_first/)
const seen=[]
const summary=await runFbaCore(cfg,{env:env(),readers:readers(),outputRoot:root(),createContext:mode=>({context:{},async run(){seen.push(mode);return pass()}})})
assert.equal(summary.success,true);assert.deepEqual(seen,['ROOT_FIRST']);assert.equal(summary.modes.length,1)
for(const args of [['--signature-mode','ALL'],['--signature-mode','root_first'],['--signature-mode','ROOT_FIRST','--signature-mode','CHILD_FIRST']])assert.throws(()=>parseCoreArguments(args))
})
test('both tax modes pass unchanged to all three existing contexts and persist on all report surfaces',async()=>{ test('both tax modes pass unchanged to all three existing contexts and persist on all report surfaces',async()=>{
for(const taxMode of ['BUY_DECLARATION','TAX_REFUND']){ for(const taxMode of ['BUY_DECLARATION','TAX_REFUND']){
const outputRoot=root(),cfg={...config(),taxMode},seen=[] const outputRoot=root(),cfg={...config(),taxMode},seen=[]
@@ -119,3 +129,32 @@ test('reports redact dynamically created account secrets and environment/token v
assert.equal(value.password,'[REDACTED]');assert.equal(value.pdaToken,'[REDACTED]') assert.equal(value.password,'[REDACTED]');assert.equal(value.pdaToken,'[REDACTED]')
assert.equal(value.message,'failure [REDACTED]');assert.equal(value.nested.value,'safe') assert.equal(value.message,'failure [REDACTED]');assert.equal(value.nested.value,'safe')
}) })
test('delivery fault requires one explicit mode and verified proof plus restoration, even after caught run failures', async()=>{
assert.throws(()=>parseCoreArguments(['--plan','--verify-delivery-event-recovery']))
const cfg=parseCoreArguments(['--execute','--environment','TEST','--run-id','fault-one','--signature-mode','ROOT_FIRST','--verify-delivery-event-recovery'])
assert.equal(executionPlan(cfg).verifyDeliveryEventRecovery,true)
for(const failure of ['none','context','proof','restore']) {
const outputRoot=root();let cleanups=0,prepares=0
const r={...readers(),readFleetInventory:async scope=>({scope,balances:[],positions:[],ledger:[]})}
const summary=await runFbaCore(cfg,{env:{...env(),CHONGCHONG_ENV:'test'},readers:r,outputRoot,
faultFactory:()=>({prepare:async()=>{prepares++},cleanup:async()=>{
cleanups++;if(failure==='restore')throw Error('private failure')
return {restored:true,healthy:true,proxyStopped:true}
}}),createContext:()=>({context:{},createdResources:{},async run(){
await this.context.f40DeliveryEventFaultExecutor.prepare({marker:'F40',customerId:'90',deliveryId:'91'})
await this.context.f40ReadOnlyInventoryEvidence({marker:'F40',customerId:'90',warehouseId:'92'})
if(failure==='context')throw Error('private failure')
const report=pass()
if(failure!=='proof')report.createdResources.phase5RootFulfillment.deliveryEventRecovery={status:'VERIFIED'}
return report
}})})
assert.equal(summary.success,failure==='none')
assert.equal(cleanups,1);assert.equal(prepares,1)
const evidencePath=join(outputRoot,cfg.outputDir,'evidence')
assert.ok(existsSync(join(evidencePath,'root_first-delivery-scope.json')))
assert.ok(existsSync(join(evidencePath,'root_first-0001-inventory.json')))
const report=JSON.parse(readFileSync(join(outputRoot,cfg.outputDir,'root_first.json'),'utf8'))
if(failure==='restore')assert.equal(report.deliveryRestorationFailed,true)
}
})
+76
View File
@@ -0,0 +1,76 @@
import { createHash } from 'node:crypto'
import { isDeepStrictEqual } from 'node:util'
export const deliveryFaultScopeKeys = ['marker','customerId','deliveryId','sourceInboundId','sourceOrderId','rootOrderId']
const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object'
? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value
export function assertTestDeliveryFaultProof(proof, scope) {
const need = (value, code) => { if (!value) throw Error('Invalid delivery fault proof: ' + code) }
need(proof?.readOnly === true && deliveryFaultScopeKeys.every(key => proof[key] === scope[key]), 'SCOPE')
const row=proof.producer, before=proof.beforeRetry, after=proof.afterRetry, env=row?.envelope, payload=env?.payload
need(row && /^[1-9][0-9]{0,19}$/.test(row.entryId) && /^(?:[0-9a-fA-F-]{36}|[0-9A-HJKMNP-TV-Z]{26})$/.test(row.eventId), 'IDENTITY')
need(row.eventType==='OverseasDeliveryCompleted' && row.customerId===scope.customerId && row.aggregateType==='INBOUND_ORDER'
&& row.aggregateId===scope.sourceInboundId && payload?.deliveryOrderId===scope.deliveryId
&& payload.sourceInboundId===scope.sourceInboundId && payload.sourceOrderId===scope.sourceOrderId
&& payload.rootOrderId===scope.rootOrderId && payload.deliveryNo && payload.inboundCode && payload.sourceOrderNo
&& payload.fbaWarehouseCode && payload.lastlegOrderId && payload.lastlegOrderNo && Number.isFinite(Date.parse(payload.completedAt))
&& row.eventKey===`OverseasDeliveryCompleted:${payload.deliveryNo}:${scope.sourceInboundId}`, 'PRODUCER')
need(env.eventId===row.eventId && env.eventType===row.eventType && env.tenantId===scope.customerId
&& Number(env.eventVersion)===1 && env.aggregateType==='INBOUND_ORDER' && env.aggregateId===scope.sourceInboundId
&& Number(env.aggregateVersion)>0, 'ENVELOPE')
const hash='sha256:'+createHash('sha256').update(JSON.stringify(canonical(payload))).digest('hex')
need(row.payloadHash===hash && env.payloadHash===hash, 'HASH')
need(before?.publishStatus==='DEAD' && before.failedAttempts===12 && row.publishStatus==='PUBLISHED'
&& after?.publishStatus==='PUBLISHED' && [before,after].every(value => value.entryId===row.entryId
&& value.eventId===row.eventId && value.payloadHash===hash && isDeepStrictEqual(value.envelope,env)), 'ORIGINAL_RETRY')
const inbox=proof.consumer
need(inbox?.count===1 && inbox.eventId===row.eventId && inbox.eventType===row.eventType && inbox.aggregateId===scope.sourceInboundId
&& inbox.status==='PROCESSED' && inbox.payloadHash===hash && isDeepStrictEqual(inbox.payload,payload), 'INBOX')
const attempts=proof.attempts, matches=proof.proxyRecords?.filter(value=>value.matched===true)
need(Array.isArray(attempts) && attempts.length===13 && new Set(attempts.map(value=>value.attemptId)).size===13
&& attempts.every((value,index)=>value.entryId===row.entryId && value.eventId===row.eventId && value.serviceName==='lastleg'
&& value.outcome===(index<11?'RETRY':index===11?'DEAD':'DUPLICATE')), 'ATTEMPTS')
need(matches?.length===13 && matches.every((value,index)=>value.eventId===row.eventId && value.payloadHash===hash
&& value.attempt===index+1 && value.outcome===(index===0?'APPLIED':'DUPLICATE')
&& (index<12?value.errorCode==='ACK_LOSS_INJECTED':value.errorCode===null)), 'REAL_ACK_LOSS')
const audit=proof.retryAudit
need(proof.retryAuditCount===1 && audit?.requestId===proof.retryRequestId && audit.entryId===row.entryId
&& audit.eventId===row.eventId && audit.payloadHash===hash && audit.previousFailures===12, 'RETRY_AUDIT')
const facts=before.business
need(facts && isDeepStrictEqual(facts,after.business), 'BUSINESS_UNCHANGED')
for (const key of ['inbound','inboundHistory','inbox','order','orderHistory','completionEvent']) need(Array.isArray(facts[key]), 'BUSINESS_SHAPE')
need(facts.inbox.length===1 && facts.inbox[0].eventId===row.eventId && facts.inbox[0].status==='PROCESSED'
&& facts.inbox[0].payloadHash===hash && facts.inbox[0].processedAt && isDeepStrictEqual(facts.inbox[0].payload,payload), 'PERSISTED_INBOX')
const inbounds=facts.inbound.filter(value=>value.id===scope.sourceInboundId)
need(inbounds.length===1 && inbounds[0].customerId===scope.customerId && inbounds[0].orderId===scope.sourceOrderId
&& inbounds[0].orderNo===payload.sourceOrderNo && inbounds[0].inboundCode===payload.inboundCode
&& inbounds[0].processStatus==='DELIVERED' && Number.isInteger(inbounds[0].version), 'INBOUND')
need(facts.inboundHistory.filter(value=>value.inboundId===scope.sourceInboundId && value.action==='COMPLETE_DELIVERY'
&& value.fromStatus==='LOADED' && value.toStatus==='DELIVERED').length===1, 'INBOUND_HISTORY')
const instant=payload.completedAt.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z$/)
const completedMillis=instant?Date.parse(instant[1]+'Z')+Math.floor((Number((instant[2]||'').padEnd(9,'0'))+500000)/1000000):NaN
const storedMillis=value=>Date.parse(String(value).replace(' ','T').replace(/Z$/,'')+'Z')
need(facts.inboundHistory.filter(value=>value.action==='COMPLETE_DELIVERY')
.every(value=>storedMillis(value.occurredAt)===completedMillis), 'COMPLETION_TIME')
need(facts.order.length>0 && new Set(facts.order.map(value=>value.id)).size===facts.order.length
&& facts.order.every(value=>value.customerId===scope.customerId && value.rootOrderId===scope.rootOrderId && Number.isInteger(value.version)), 'ORDER_SCOPE')
const source=facts.order.find(value=>value.id===scope.sourceOrderId), root=facts.order.find(value=>value.id===scope.rootOrderId)
need(source && root && root.orderRole==='MASTER' && source.orderNo===payload.sourceOrderNo && source.inboundCode===payload.inboundCode, 'ORDER_IDENTITY')
const historyCount=(id,action)=>facts.orderHistory.filter(value=>value.orderId===id && value.action===action).length
need(historyCount(source.id,'OVERSEAS_DELIVERED')===1, 'SOURCE_SIGNED')
need(facts.orderHistory.filter(value=>value.orderId===source.id && value.action==='OVERSEAS_DELIVERED')
.every(value=>storedMillis(value.occurredAt)===completedMillis), 'SOURCE_SIGNED_TIME')
const rootCanComplete=historyCount(root.id,'OVERSEAS_DELIVERED')===1
&& facts.order.filter(value=>value.id!==root.id && value.status!=='CANCELLED').every(value=>value.status==='COMPLETED')
need(rootCanComplete ? root.status==='COMPLETED' : ['APPROVED','ACCEPTED','PROCESSING'].includes(root.status), 'ROOT_GATE')
need(source.id===root.id || source.status==='COMPLETED', 'SOURCE_COMPLETION')
need(source.status==='COMPLETED'?inbounds[0].orderStatusSnapshot==='COMPLETED':inbounds[0].orderStatusSnapshot!=='COMPLETED', 'INBOUND_ORDER_SNAPSHOT')
for (const order of facts.order) {
const events=facts.completionEvent.filter(value=>value.orderId===order.id)
need(events.length===(order.status==='COMPLETED'?1:0) && events.every(value=>value.status==='PUBLISHED'
&& value.eventType==='FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'), 'COMPLETION_EVENTS')
if(order.status==='COMPLETED') need(order.completedAt && historyCount(order.id,order.id===root.id?'FULFILLMENT_COMPLETED':'OVERSEAS_DELIVERED')===1, 'COMPLETION_HISTORY')
}
need(facts.completionEvent.every(value=>facts.order.some(order=>order.id===value.orderId)), 'FOREIGN_EVENT')
return proof
}
+16 -10
View File
@@ -3,6 +3,8 @@
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { isDeepStrictEqual } from 'node:util' import { isDeepStrictEqual } from 'node:util'
import { assertTestDeliveryFaultProof, deliveryFaultScopeKeys } from './test-delivery-fault-proof.mjs'
export { assertTestDeliveryFaultProof } from './test-delivery-fault-proof.mjs'
import { validateEvidenceConfig } from './fba-core-regression-evidence.mjs' import { validateEvidenceConfig } from './fba-core-regression-evidence.mjs'
const runtime = readFileSync(new URL('./test-event-fault-runtime.py', import.meta.url), 'utf8') const runtime = readFileSync(new URL('./test-event-fault-runtime.py', import.meta.url), 'utf8')
@@ -17,11 +19,11 @@ export function renderTestUnloadFaultAction(action, scope, agent) {
return renderAction(action,scope,agent,'UNLOAD') return renderAction(action,scope,agent,'UNLOAD')
} }
function renderAction(action,scope,agent,direction) { function renderAction(action,scope,agent,direction) {
const keys=direction==='UNLOAD'?['marker','customerId','containerBookingId']:['marker','customerId','loadingPlanId','containerBookingId'] const keys=direction==='DELIVERY'?deliveryFaultScopeKeys:direction==='UNLOAD'?['marker','customerId','containerBookingId']:['marker','customerId','loadingPlanId','containerBookingId']
if (!['prepare', 'activate', 'proxy-ready', 'health', 'status', 'recover', 'proof', 'restore', 'finish'].includes(action) if (!['prepare', 'activate', 'proxy-ready', 'health', 'status', 'recover', 'proof', 'restore', 'finish'].includes(action)
|| !/^[A-Za-z0-9_.-]{1,128}$/.test(agent) || !/^[A-Za-z0-9_-]{1,96}$/.test(scope?.marker || '') || !/^[A-Za-z0-9_.-]{1,128}$/.test(agent) || !/^[A-Za-z0-9_-]{1,96}$/.test(scope?.marker || '')
|| keys.filter(key=>key!=='marker').some(key => !/^[1-9][0-9]{0,19}$/.test(scope?.[key] || '')) || keys.filter(key=>key!=='marker').some(key => !/^[1-9][0-9]{0,19}$/.test(scope?.[key] || ''))
|| (direction==='UNLOAD'&&Object.keys(scope).some(key=>!keys.includes(key)))) { || (direction!=='LOADING'&&Object.keys(scope).some(key=>!keys.includes(key)))) {
throw Error('Invalid TEST fault action or scope') throw Error('Invalid TEST fault action or scope')
} }
const config = encode(JSON.stringify({ action, scope, direction, ...(action === 'prepare' ? { proxySource } : {}) })) const config = encode(JSON.stringify({ action, scope, direction, ...(action === 'prepare' ? { proxySource } : {}) }))
@@ -32,6 +34,9 @@ function renderAction(action,scope,agent,direction) {
export function createTestFaultExecutor(env, fetcher = globalThis.fetch) { export function createTestFaultExecutor(env, fetcher = globalThis.fetch) {
return createExecutor(env,fetcher,'LOADING') return createExecutor(env,fetcher,'LOADING')
} }
export function createTestDeliveryFaultExecutor(env, fetcher = globalThis.fetch) {
return createExecutor(env,fetcher,'DELIVERY')
}
export function createTestUnloadFaultExecutor(env, fetcher = globalThis.fetch) { export function createTestUnloadFaultExecutor(env, fetcher = globalThis.fetch) {
return createExecutor(env,fetcher,'UNLOAD') return createExecutor(env,fetcher,'UNLOAD')
} }
@@ -117,23 +122,23 @@ function createExecutor(env,fetcher,direction) {
await action('restore') await action('restore')
await wait('health', row => row.healthy) await wait('health', row => row.healthy)
restoration=await action('finish') restoration=await action('finish')
if(direction==='UNLOAD'&&!(/^sha256:[0-9a-f]{64}$/.test(preparedEvidence?.originalImageId||'') if(direction!=='LOADING'&&!(/^sha256:[0-9a-f]{64}$/.test(preparedEvidence?.originalImageId||'')
&&/^[0-9a-f]{64}$/.test(preparedEvidence?.originalFingerprint||'') &&/^[0-9a-f]{64}$/.test(preparedEvidence?.originalFingerprint||'')
&&restoration?.restored===true&&restoration.healthy===true&&restoration.proxyStopped===true &&restoration?.restored===true&&restoration.healthy===true&&restoration.proxyStopped===true
&&restoration.producerName==='firstleg-test-lastleg'&&restoration.originalImageId===preparedEvidence?.originalImageId &&restoration.producerName==='firstleg-test-lastleg'&&restoration.originalImageId===preparedEvidence?.originalImageId
&&restoration.currentImageId===preparedEvidence?.originalImageId&&restoration.originalFingerprint===preparedEvidence?.originalFingerprint &&restoration.currentImageId===preparedEvidence?.originalImageId&&restoration.originalFingerprint===preparedEvidence?.originalFingerprint
&&restoration.currentFingerprint===preparedEvidence?.originalFingerprint))throw Error('Unload restoration evidence mismatch') &&restoration.currentFingerprint===preparedEvidence?.originalFingerprint))throw Error((direction==='DELIVERY'?'Delivery':'Unload')+' restoration evidence mismatch')
finished = true finished = true
return restoration return restoration
} }
return { return {
async prepare(next) { async prepare(next) {
if (scope) { if (scope) {
if(direction==='UNLOAD'&&!isDeepStrictEqual(scope,next))throw Error('TEST unload fault scope drift') if(direction!=='LOADING'&&!isDeepStrictEqual(scope,next))throw Error('TEST unload fault scope drift')
return // Pin only the first actual current-run plan; other events pass through. return // Pin only the first actual current-run plan; other events pass through.
} }
if(direction==='UNLOAD')renderAction('prepare',next,env.FBA_CORE_JENKINS_AGENT,direction) if(direction!=='LOADING')renderAction('prepare',next,env.FBA_CORE_JENKINS_AGENT,direction)
scope = direction==='UNLOAD'?{...next}:{ marker: next.marker, customerId: next.customerId, scope = direction!=='LOADING'?{...next}:{ marker: next.marker, customerId: next.customerId,
loadingPlanId: next.loadingPlanId, containerBookingId: next.containerBookingId } loadingPlanId: next.loadingPlanId, containerBookingId: next.containerBookingId }
preparedEvidence=await action('prepare') preparedEvidence=await action('prepare')
await wait('proxy-ready', row => row.ready, 10, true) await wait('proxy-ready', row => row.ready, 10, true)
@@ -141,16 +146,17 @@ function createExecutor(env,fetcher,direction) {
await wait('health', row => row.healthy) await wait('health', row => row.healthy)
}, },
async readProof(expected) { async readProof(expected) {
if (!scope || (direction==='UNLOAD'?!isDeepStrictEqual(scope,expected):Object.keys(scope).some(key => expected?.[key] !== scope[key]))) throw Error('TEST fault scope drift') if (!scope || (direction!=='LOADING'?!isDeepStrictEqual(scope,expected):Object.keys(scope).some(key => expected?.[key] !== scope[key]))) throw Error('TEST fault scope drift')
try { try {
await wait('status', row => row.producer?.publishStatus === 'DEAD' && row.producer.failedAttempts === 12, 180) await wait('status', row => row.producer?.publishStatus === 'DEAD' && row.producer.failedAttempts === 12, 180)
await action('recover') await action('recover')
await wait('status', row => row.producer?.publishStatus === 'PUBLISHED', 90) await wait('status', row => row.producer?.publishStatus === 'PUBLISHED', 90)
const proof = await action('proof') const proof = await action('proof')
if(direction==='UNLOAD')assertTestUnloadFaultProof(proof,scope) if(direction==='UNLOAD')assertTestUnloadFaultProof(proof,scope)
if(direction==='DELIVERY')assertTestDeliveryFaultProof(proof,scope)
await cleanup() await cleanup()
return { ...proof, restored: true, ...(direction==='UNLOAD'?{restoration}:{}) } return { ...proof, restored: true, ...(direction!=='LOADING'?{restoration}:{}) }
} finally { if(direction==='UNLOAD')await cleanup() } } finally { if(direction!=='LOADING')await cleanup() }
}, },
cleanup, cleanup,
} }
+136 -1
View File
@@ -2,7 +2,8 @@ import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process' import { spawnSync } from 'node:child_process'
import { createTestFaultExecutor, createTestUnloadFaultExecutor, assertTestUnloadFaultProof, renderTestFaultAction } from './test-event-fault-executor.mjs' import { createTestFaultExecutor, createTestUnloadFaultExecutor, createTestDeliveryFaultExecutor, assertTestDeliveryFaultProof, assertTestUnloadFaultProof, renderTestFaultAction } from './test-event-fault-executor.mjs'
import { withDeliveryEventRecovery } from './admin-test-env-modules/phase5-root-fulfillment.mjs'
const scope = { marker: 'TEST-ROOT', customerId: '7', loadingPlanId: '99', containerBookingId: '66' } const scope = { marker: 'TEST-ROOT', customerId: '7', loadingPlanId: '99', containerBookingId: '66' }
const environment = { CHONGCHONG_ENV: 'test', JENKINS_URL: 'https://jenkins.example.invalid/', const environment = { CHONGCHONG_ENV: 'test', JENKINS_URL: 'https://jenkins.example.invalid/',
@@ -219,3 +220,137 @@ test('failed activation retains task ownership for explicit restoration, never r
assert.equal(calls.filter(name => name === 'activate').length, 1) assert.equal(calls.filter(name => name === 'activate').length, 1)
assert.deepEqual(calls.slice(-3), ['restore', 'health', 'finish']) assert.deepEqual(calls.slice(-3), ['restore', 'health', 'finish'])
}) })
const deliveryScope={marker:'TEST-DELIVERY',customerId:'7',deliveryId:'90',sourceInboundId:'200',sourceOrderId:'100',rootOrderId:'100'}
function deliveryProof(mode='ROOT_PENDING') {
const scope={...deliveryScope,...(mode==='CHILD_FIRST'?{sourceOrderId:'101',sourceInboundId:'201'}:{})}
const eventId='00000000-0000-4000-8000-000000000001', entryId='123'
const payload={deliveryOrderId:'90',deliveryNo:'DL-OFFLINE',lastlegOrderId:'300',lastlegOrderNo:'LL-OFFLINE',
sourceInboundId:scope.sourceInboundId,inboundCode:'IN-OFFLINE',sourceOrderId:scope.sourceOrderId,
sourceOrderNo:'FL-OFFLINE',rootOrderId:'100',fbaWarehouseCode:'ABE4',completedAt:'2026-10-03T01:00:00Z'}
const payloadHash='sha256:'+createHash('sha256').update(JSON.stringify(payload,Object.keys(payload).sort())).digest('hex')
const envelope={eventId,eventType:'OverseasDeliveryCompleted',eventVersion:1,tenantId:'7',aggregateType:'INBOUND_ORDER',aggregateId:scope.sourceInboundId,aggregateVersion:5,payloadHash,payload}
const producer={entryId,eventId,eventType:envelope.eventType,customerId:'7',aggregateId:scope.sourceInboundId,aggregateType:'INBOUND_ORDER',eventKey:`OverseasDeliveryCompleted:DL-OFFLINE:${scope.sourceInboundId}`,envelope,payloadHash,publishStatus:'PUBLISHED'}
const ownRoot=mode!=='CHILD_FIRST', childComplete=mode!=='ROOT_PENDING', rootComplete=mode==='ALL_COMPLETE'
const orders=['100','101'].map(id=>({id,rootOrderId:'100',customerId:'7',orderRole:id==='100'?'MASTER':'CHILD',
orderNo:id===scope.sourceOrderId?'FL-OFFLINE':'OTHER',inboundCode:id===scope.sourceOrderId?'IN-OFFLINE':'OTHER',
status:(id==='100'?rootComplete:childComplete)?'COMPLETED':'APPROVED',version:3,
completedAt:(id==='100'?rootComplete:childComplete)?'2026-10-03 01:00:00':null}))
const consumer={eventId,eventType:envelope.eventType,aggregateId:scope.sourceInboundId,payloadHash,payload,status:'PROCESSED'}
const business={inbound:[{id:scope.sourceInboundId,customerId:'7',orderId:scope.sourceOrderId,orderNo:'FL-OFFLINE',inboundCode:'IN-OFFLINE',processStatus:'DELIVERED',orderStatusSnapshot:orders.find(o=>o.id===scope.sourceOrderId).status,version:2}],
inboundHistory:[{id:'41',inboundId:scope.sourceInboundId,action:'COMPLETE_DELIVERY',fromStatus:'LOADED',toStatus:'DELIVERED',occurredAt:'2026-10-03 01:00:00'}],
inbox:[{...consumer,processedAt:'2026-10-03 01:00:01'}],order:orders,
orderHistory:[...(ownRoot?[{id:'51',orderId:'100',action:'OVERSEAS_DELIVERED',occurredAt:'2026-10-03 01:00:00'}]:[]),...(childComplete?[{id:'52',orderId:'101',action:'OVERSEAS_DELIVERED',occurredAt:'2026-10-03 01:00:00'}]:[]),...(rootComplete?[{id:'53',orderId:'100',action:'FULFILLMENT_COMPLETED'}]:[])],
completionEvent:orders.filter(o=>o.status==='COMPLETED').map((o,i)=>({id:String(60+i),orderId:o.id,eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED',status:'PUBLISHED',payload:{orderId:o.id}}))}
const old=unloadProof()
return JSON.parse(JSON.stringify({...scope,readOnly:true,producer,consumer:{...consumer,count:1},
beforeRetry:{...producer,publishStatus:'DEAD',failedAttempts:12,business},afterRetry:{...producer,business},
attempts:old.attempts,proxyRecords:old.proxyRecords.map(p=>({...p,payloadHash})),retryAudit:{...old.retryAudit,payloadHash},retryAuditCount:1,retryRequestId:old.retryRequestId}))
}
function deliveryTransport(calls, proof=deliveryProof(), finish={}) {
let recovered=false
return async(_url,options)=>{
const {action,direction,scope}=requestedAction(options)
assert.equal(direction,'DELIVERY');assert.deepEqual(scope,deliveryScope);calls.push(action)
if(action==='recover')recovered=true
const data=action==='prepare'?{originalImageId,originalFingerprint}:action==='proxy-ready'?{ready:true}:action==='health'?{healthy:true}
:action==='status'?{producer:{publishStatus:recovered?'PUBLISHED':'DEAD',failedAttempts:12}}:action==='proof'?proof
:action==='finish'?{restored:true,healthy:true,proxyStopped:true,producerName:'firstleg-test-lastleg',originalImageId,currentImageId:originalImageId,originalFingerprint,currentFingerprint:originalFingerprint,...finish}:{}
return {ok:true,text:async()=> 'TEST_EVENT_JSON='+JSON.stringify({ok:true,data})}
}
}
test('DELIVERY has a separate strict scope and restores before accepting original event proof',async()=>{
const calls=[],executor=createTestDeliveryFaultExecutor(environment,deliveryTransport(calls))
await assert.rejects(executor.prepare({...deliveryScope,eventType:'OverseasContainerUnloaded'}),/Invalid/)
await executor.prepare(deliveryScope)
await assert.rejects(executor.prepare({...deliveryScope,deliveryId:'91'}),/scope drift/)
const proof=await executor.readProof(deliveryScope)
assert.equal(proof.restored,true);assert.equal(proof.restoration.currentImageId,originalImageId)
assert.deepEqual(calls,['prepare','proxy-ready','activate','health','status','recover','status','proof','restore','health','finish'])
})
test('DELIVERY proof distinguishes root pending, child first and all completed with immutable original evidence',()=>{
for(const mode of ['ROOT_PENDING','CHILD_FIRST','ALL_COMPLETE']) {
const p=deliveryProof(mode),scope={...deliveryScope,sourceOrderId:p.sourceOrderId,sourceInboundId:p.sourceInboundId}
assertTestDeliveryFaultProof(p,scope)
}
for(const mutate of [p=>p.producer.envelope.payload.deliveryId='90',p=>delete p.producer.envelope.payload.deliveryOrderId,
p=>p.producer.customerId='0',p=>p.producer.eventKey='wrong',p=>p.beforeRetry.entryId='124',
p=>p.consumer.count=2,p=>p.attempts.pop(),p=>p.proxyRecords[0].outcome='DUPLICATE',
p=>p.retryAuditCount=2,p=>p.afterRetry.business.inbound[0].version++,p=>p.afterRetry.business.order[0].version++,
p=>p.beforeRetry.business.inbox[0].status='RECEIVED',p=>p.beforeRetry.business.inboundHistory.push({...p.beforeRetry.business.inboundHistory[0]}),
p=>{p.beforeRetry.business.order[0].status=p.afterRetry.business.order[0].status='COMPLETED'},
p=>p.beforeRetry.business.completionEvent.push({orderId:'100',status:'PUBLISHED'})]) {
const proof=deliveryProof();mutate(proof);assert.throws(()=>assertTestDeliveryFaultProof(proof,deliveryScope),/Invalid delivery/)
}
})
test('DELIVERY evidence failure still restores and restoration drift is a blocking failure',async()=>{
const p=deliveryProof();p.consumer.count=0
const calls=[],executor=createTestDeliveryFaultExecutor(environment,deliveryTransport(calls,p))
await executor.prepare(deliveryScope);await assert.rejects(executor.readProof(deliveryScope),/INBOX/)
assert.deepEqual(calls.slice(-3),['restore','health','finish'])
const broken=createTestDeliveryFaultExecutor(environment,deliveryTransport([],deliveryProof(),{proxyStopped:false}))
await broken.prepare(deliveryScope);await assert.rejects(broken.readProof(deliveryScope),/Delivery restoration/)
})
test('F40 hook faults only first signature, captures whole inventory and always cleans failed signing',async()=>{
const calls=[],restoration={restored:true,healthy:true,proxyStopped:true}
const inventory={scope:{...deliveryScope,warehouseId:'9',coverage:'CUSTOMER_WAREHOUSE_COMPLETE'},balances:[],positions:[],ledger:[]}
const context={context:{f40DeliveryEventFaultExecutor:{prepare:async s=>{assert.deepEqual(s,deliveryScope);calls.push('prepare')},
readProof:async()=>{calls.push('proof');return {...deliveryProof(),restored:true,restoration}},cleanup:async()=>{calls.push('cleanup');return restoration}},
f40ReadOnlyInventoryEvidence:async s=>{assert.equal(s.warehouseId,'9');calls.push('inventory');return structuredClone(inventory)}},createdResources:{phase5RootFulfillment:{}}}
assert.equal(await withDeliveryEventRecovery(context,deliveryScope,'9',0,async()=>{calls.push('sign');return 'signed'}),'signed')
assert.deepEqual(calls,['inventory','prepare','sign','proof','inventory','cleanup'])
assert.equal(context.createdResources.phase5RootFulfillment.deliveryEventRecovery.status,'VERIFIED')
await withDeliveryEventRecovery(context,deliveryScope,'9',1,async()=>calls.push('second'))
assert.equal(calls.at(-1),'second')
context.createdResources.phase5RootFulfillment={}
await assert.rejects(withDeliveryEventRecovery(context,deliveryScope,'9',0,async()=>{throw Error('sign failed')}),/sign failed/)
assert.equal(calls.at(-1),'cleanup')
assert.equal(context.createdResources.phase5RootFulfillment.deliveryEventRecovery.restored,true)
})
test('Python DELIVERY SQL uses actual payload, original Lastleg retry, and Order database without inventory queries',()=>{
const result=spawnSync('python',['-c',String.raw`
import importlib.util,sys,ast,pathlib,copy
from unittest.mock import patch,MagicMock,mock_open
sys.dont_write_bytecode=True
p=pathlib.Path('scripts/test-event-fault-runtime.py');ast.parse(p.read_text(encoding='utf-8'))
spec=importlib.util.spec_from_file_location('runtime',p);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
s={'marker':'TEST-DELIVERY','customerId':'7','deliveryId':'90','sourceInboundId':'200','sourceOrderId':'100','rootOrderId':'100'}
assert m.scope_directory(s,'DELIVERY')!=m.scope_directory({'marker':s['marker'],'customerId':'7','containerBookingId':'200'},'UNLOAD')
assert m.producer_name({'direction':'DELIVERY'})=='firstleg-test-lastleg'
assert m.target_setting({'direction':'DELIVERY'})==('FIRSTLEG_SERVICE_URL','http://firstleg:9202')
queries=[]
with patch.object(m,'inspect',side_effect=lambda name:{'Name':'/'+name}),patch.object(m,'require_test'),patch.object(m,'read_sql',side_effect=lambda container,sql:queries.append((container['Name'],sql)) or []):
assert m.producer(s,'DELIVERY') is None
m.delivery_facts(s,'00000000-0000-4000-8000-000000000001')
assert [q[0] for q in queries]==['/firstleg-test-lastleg','/firstleg-test-business','/firstleg-test-order']
assert "$.payload.deliveryOrderId" in queries[0][1] and "customer_id='7'" in queries[0][1]
assert 'fl_inbound_status_history' in queries[1][1] and 'fl_container_event_inbox' in queries[1][1]
assert 'ord_order_status_history' in queries[2][1] and 'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED' in queries[2][1]
assert all('inventory' not in q[1] for q in queries)
original={'Name':'/firstleg-test-lastleg','Id':'original-container','Image':'sha256:'+'a'*64,
'Config':{'Image':'lastleg:test','Env':['FIRSTLEG_SERVICE_URL=http://firstleg:9202'],'Labels':{'com.docker.compose.project':'firstleg-test'}},
'HostConfig':{},'Mounts':[],'NetworkSettings':{'Networks':{'firstleg-test-network':{'IPAddress':'172.20.0.3','Aliases':['lastleg-service']}}},
'State':{'Running':True,'Health':{'Status':'healthy'}}}
consumer=copy.deepcopy(original);consumer['Name']='/firstleg-test-business'
with patch.object(m,'producer',return_value=None),patch.object(m,'inspect',side_effect=lambda name:original if name=='firstleg-test-lastleg' else consumer),patch.object(m.Path,'mkdir'),patch.object(m.Path,'write_bytes'),patch('builtins.open',mock_open()),patch.object(m.socket,'socket') as sock,patch.object(m.subprocess,'Popen') as process,patch.object(m,'private_write'):
sock.return_value.getsockname.return_value=('127.0.0.1',18001)
m.prepare(s,pathlib.Path('/offline'),{'direction':'DELIVERY','proxySource':''})
args=process.call_args.args[0]
assert args[args.index('--delivery-id')+1]=='90' and args[args.index('--event-type')+1]=='OverseasDeliveryCompleted'
assert '--container-booking-id' not in args and args[args.index('--upstream')+1].endswith(':9202/internal/firstleg/v1/container-events')
state={'direction':'DELIVERY','restored':False,'prepared':True,'image':'lastleg:test','originalId':'original-container',
'originalImageId':original['Image'],'originalFingerprint':m.fingerprint(original),'originalBody':m.recreation(original,m.env_of(original),full=True),'proxyPort':18001}
restored=copy.deepcopy(original);restored['Id']='restored-new-id'
with patch.object(m,'control',side_effect=OSError('proxy already stopped')),patch.object(m,'inspect',return_value=restored),patch.object(m,'private_write'),patch.object(m,'replace') as replace,patch.object(m,'docker') as docker:
assert m.restore(pathlib.Path('/offline'),state)=={'restoreStarted':True}
assert state['prepared'] is False
replace.assert_not_called();docker.assert_not_called()
state['prepared']=True
restored['Config']['Env']=['FIRSTLEG_SERVICE_URL=http://firstleg:9202','UNEXPECTED=1']
with patch.object(m,'control'),patch.object(m,'inspect',return_value=restored),patch.object(m,'replace') as replace:
try: m.restore(pathlib.Path('/offline'),state);assert False
except RuntimeError as error: assert str(error)=='CONCURRENT_PRODUCER_CHANGE'
replace.assert_not_called()
`],{encoding:'utf8',cwd:new URL('..',import.meta.url),timeout:10000})
assert.equal(result.status,0,result.stderr||result.error?.message)
})
+71 -26
View File
@@ -103,18 +103,18 @@ def private_write(path, value):
def scope_directory(scope, direction='LOADING'): def scope_directory(scope, direction='LOADING'):
if direction not in ('LOADING', 'UNLOAD'): if direction not in ('LOADING', 'UNLOAD', 'DELIVERY'):
raise RuntimeError('INVALID_DIRECTION') raise RuntimeError('INVALID_DIRECTION')
marker = scope.get('marker', '') marker = scope.get('marker', '')
if not re.fullmatch(r'[A-Za-z0-9_-]{1,96}', marker): if not re.fullmatch(r'[A-Za-z0-9_-]{1,96}', marker):
raise RuntimeError('INVALID_SCOPE') raise RuntimeError('INVALID_SCOPE')
keys = ('customerId', 'containerBookingId') if direction == 'UNLOAD' else ('customerId', 'loadingPlanId', 'containerBookingId') keys = ('customerId', 'deliveryId', 'sourceInboundId', 'sourceOrderId', 'rootOrderId') if direction == 'DELIVERY' else ('customerId', 'containerBookingId') if direction == 'UNLOAD' else ('customerId', 'loadingPlanId', 'containerBookingId')
if direction == 'UNLOAD' and set(scope) != {'marker', *keys}: if direction != 'LOADING' and set(scope) != {'marker', *keys}:
raise RuntimeError('INVALID_SCOPE') raise RuntimeError('INVALID_SCOPE')
for key in keys: for key in keys:
if not re.fullmatch(r'[1-9][0-9]{0,19}', scope.get(key, '')): if not re.fullmatch(r'[1-9][0-9]{0,19}', scope.get(key, '')):
raise RuntimeError('INVALID_SCOPE') raise RuntimeError('INVALID_SCOPE')
identity = 'UNLOAD:' + marker if direction == 'UNLOAD' else marker identity = direction + ':' + marker if direction != 'LOADING' else marker
return Path(tempfile.gettempdir()) / ('chongchong-event-' + hashlib.sha256(identity.encode()).hexdigest()[:24]) return Path(tempfile.gettempdir()) / ('chongchong-event-' + hashlib.sha256(identity.encode()).hexdigest()[:24])
@@ -122,12 +122,20 @@ def unload(state):
return state.get('direction', 'LOADING') == 'UNLOAD' return state.get('direction', 'LOADING') == 'UNLOAD'
def delivery(state):
return state.get('direction') == 'DELIVERY'
def reverse_delivery(state):
return state.get('direction') in ('UNLOAD', 'DELIVERY')
def producer_name(state): def producer_name(state):
return 'firstleg-test-lastleg' if unload(state) else 'firstleg-test-business' return 'firstleg-test-lastleg' if reverse_delivery(state) else 'firstleg-test-business'
def target_setting(state): def target_setting(state):
return ('FIRSTLEG_SERVICE_URL', 'http://firstleg:9202') if unload(state) else ('LASTLEG_SERVICE_URL', 'http://lastleg-service:9303') return ('FIRSTLEG_SERVICE_URL', 'http://firstleg:9202') if reverse_delivery(state) else ('LASTLEG_SERVICE_URL', 'http://lastleg-service:9303')
def fingerprint(container): def fingerprint(container):
@@ -159,7 +167,7 @@ def read_sql(container, sql):
if re.search(r'\b(INSERT|UPDATE|DELETE|ALTER|DROP|TRUNCATE|CALL|INTO|OUTFILE)\b', sql, re.I): if re.search(r'\b(INSERT|UPDATE|DELETE|ALTER|DROP|TRUNCATE|CALL|INTO|OUTFILE)\b', sql, re.I):
raise RuntimeError('NON_READ_ONLY_SQL') raise RuntimeError('NON_READ_ONLY_SQL')
e = env_of(container) e = env_of(container)
prefix = 'FIRSTLEG' if container['Name'] == '/firstleg-test-business' else 'LASTLEG' prefix = {'/firstleg-test-business': 'FIRSTLEG', '/firstleg-test-lastleg': 'LASTLEG', '/firstleg-test-order': 'ORDER'}[container['Name']]
url = urllib.parse.urlsplit(e[prefix + '_DB_URL'].removeprefix('jdbc:')) url = urllib.parse.urlsplit(e[prefix + '_DB_URL'].removeprefix('jdbc:'))
host = '127.0.0.1' if url.hostname == 'host.docker.internal' else url.hostname host = '127.0.0.1' if url.hostname == 'host.docker.internal' else url.hostname
transaction = 'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;\n' + sql + '\nCOMMIT;' transaction = 'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;\n' + sql + '\nCOMMIT;'
@@ -174,7 +182,10 @@ def read_sql(container, sql):
def producer(scope, direction='LOADING'): def producer(scope, direction='LOADING'):
if direction == 'UNLOAD': if direction == 'DELIVERY':
rows = read_sql(inspect('firstleg-test-lastleg'),
"SELECT JSON_OBJECT('entryId',CAST(id AS CHAR),'eventId',JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.eventId')),'payloadHash',JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payloadHash')),'eventType',event_type,'customerId',customer_id,'aggregateId',aggregate_id,'aggregateType',aggregate_type,'eventKey',event_key,'envelope',JSON_EXTRACT(payload_json,'$'),'publishStatus',status,'failedAttempts',retry_count) FROM ll_event_outbox WHERE customer_id='" + scope['customerId'] + "' AND event_type='OverseasDeliveryCompleted' AND JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.deliveryOrderId'))='" + scope['deliveryId'] + "';")
elif direction == 'UNLOAD':
rows = read_sql(inspect('firstleg-test-lastleg'), rows = read_sql(inspect('firstleg-test-lastleg'),
"SELECT JSON_OBJECT('entryId',CAST(id AS CHAR),'eventId',JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.eventId')),'payloadHash',JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payloadHash')),'eventType',event_type,'customerId',customer_id,'aggregateId',aggregate_id,'aggregateType',aggregate_type,'eventKey',event_key,'envelope',JSON_EXTRACT(payload_json,'$'),'publishStatus',status,'failedAttempts',retry_count) FROM ll_event_outbox WHERE customer_id='0' AND aggregate_id='" "SELECT JSON_OBJECT('entryId',CAST(id AS CHAR),'eventId',JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.eventId')),'payloadHash',JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payloadHash')),'eventType',event_type,'customerId',customer_id,'aggregateId',aggregate_id,'aggregateType',aggregate_type,'eventKey',event_key,'envelope',JSON_EXTRACT(payload_json,'$'),'publishStatus',status,'failedAttempts',retry_count) FROM ll_event_outbox WHERE customer_id='0' AND aggregate_id='"
+ scope['containerBookingId'] + "' AND event_type='OverseasContainerUnloaded' AND event_key='OverseasContainerUnloaded:" + scope['containerBookingId'] + "';") + scope['containerBookingId'] + "' AND event_type='OverseasContainerUnloaded' AND event_key='OverseasContainerUnloaded:" + scope['containerBookingId'] + "';")
@@ -201,18 +212,50 @@ def business_ids(scope):
return [row['id'] for row in rows] return [row['id'] for row in rows]
def business_snapshot(scope, state, row):
if delivery(state):
return {'business': delivery_facts(scope, row['eventId'])}
return {'container': container_facts(scope)} if unload(state) else {'businessOrderIds': business_ids(scope)}
def delivery_facts(scope, event_id):
if not re.fullmatch(r'[0-9a-fA-F-]{36}|[0-9A-HJKMNP-TV-Z]{26}', event_id):
raise RuntimeError('INVALID_EVENT_IDENTITY')
customer, source, root = scope['customerId'], scope['sourceOrderId'], scope['rootOrderId']
firstleg = inspect('firstleg-test-business')
order = inspect('firstleg-test-order')
require_test(firstleg, 'firstleg-test-business')
require_test(order, 'firstleg-test-order')
# Each service is read in its own consistent read-only transaction; no cross-schema join.
inbound_sql = "tenant_id=" + customer + " AND deleted=0 AND order_id IN (" + source + "," + root + ")"
first_rows = read_sql(firstleg,
"SELECT JSON_OBJECT('kind','inbound','id',CAST(id AS CHAR),'customerId',CAST(tenant_id AS CHAR),'orderId',CAST(order_id AS CHAR),'orderNo',order_no,'inboundCode',inbound_code,'processStatus',process_status,'orderStatusSnapshot',order_status_snapshot,'version',version,'updatedAt',CAST(updated_at AS CHAR)) FROM fl_inbound_order WHERE " + inbound_sql + " ORDER BY id;\n"
+ "SELECT JSON_OBJECT('kind','inboundHistory','id',CAST(id AS CHAR),'inboundId',CAST(inbound_id AS CHAR),'action',action,'fromStatus',from_status,'toStatus',to_status,'occurredAt',CAST(occurred_at AS CHAR)) FROM fl_inbound_status_history WHERE tenant_id=" + customer + " AND inbound_id=" + scope['sourceInboundId'] + " ORDER BY id;\n"
+ "SELECT JSON_OBJECT('kind','inbox','eventId',event_id,'eventType',event_type,'aggregateId',aggregate_id,'aggregateVersion',aggregate_version,'payloadHash',payload_hash,'payload',JSON_EXTRACT(payload_json,'$'),'status',status,'receivedAt',CAST(received_at AS CHAR),'processedAt',CAST(processed_at AS CHAR)) FROM fl_container_event_inbox WHERE event_id='" + event_id + "';")
tree = "tenant_id=" + customer + " AND deleted=0 AND (id=" + root + " OR root_order_id=" + root + ")"
ids = "SELECT id FROM ord_order WHERE " + tree
order_rows = read_sql(order,
"SELECT JSON_OBJECT('kind','order','id',CAST(id AS CHAR),'customerId',CAST(tenant_id AS CHAR),'rootOrderId',CAST(root_order_id AS CHAR),'orderRole',order_role,'orderNo',order_no,'inboundCode',inbound_code,'status',status,'version',version,'completedAt',CAST(completed_at AS CHAR),'updatedAt',CAST(updated_at AS CHAR)) FROM ord_order WHERE " + tree + " ORDER BY id;\n"
+ "SELECT JSON_OBJECT('kind','orderHistory','id',CAST(id AS CHAR),'orderId',CAST(order_id AS CHAR),'action',action,'fromStatus',from_status,'toStatus',to_status,'occurredAt',CAST(occurred_at AS CHAR)) FROM ord_order_status_history WHERE tenant_id=" + customer + " AND order_id IN (" + ids + ") ORDER BY id;\n"
+ "SELECT JSON_OBJECT('kind','completionEvent','id',CAST(id AS CHAR),'orderId',CAST(aggregate_id AS CHAR),'eventType',event_type,'status',publish_status,'payload',JSON_EXTRACT(payload_json,'$')) FROM ord_outbox_event WHERE tenant_id=" + customer + " AND aggregate_id IN (" + ids + ") AND event_type='FIRST_LEG_ORDER_FULFILLMENT_COMPLETED' ORDER BY id;")
return {kind: [row for row in first_rows + order_rows if row['kind'] == kind] for kind in
('inbound', 'inboundHistory', 'inbox', 'order', 'orderHistory', 'completionEvent')}
def prepare(scope, directory, config): def prepare(scope, directory, config):
if delivery(config) and producer(scope, 'DELIVERY') is not None:
raise RuntimeError('DELIVERY_EVENT_ALREADY_EXISTS')
directory.mkdir(mode=0o700) directory.mkdir(mode=0o700)
original = inspect(producer_name(config)) original = inspect(producer_name(config))
require_test(original, producer_name(config)) require_test(original, producer_name(config))
consumer_name = 'firstleg-test-business' if unload(config) else 'firstleg-test-lastleg' consumer_name = 'firstleg-test-business' if reverse_delivery(config) else 'firstleg-test-lastleg'
consumer = inspect(consumer_name) consumer = inspect(consumer_name)
require_test(consumer, consumer_name) require_test(consumer, consumer_name)
old_env = env_of(original) old_env = env_of(original)
setting, direct = target_setting(config) setting, direct = target_setting(config)
if old_env.get(setting) != direct: if old_env.get(setting) != direct:
raise RuntimeError('PRODUCER_NOT_DIRECT') raise RuntimeError('PRODUCER_NOT_DIRECT')
original_body = recreation(original, old_env, full=unload(config)) original_body = recreation(original, old_env, full=reverse_delivery(config))
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
# Reserve task-specific ports and let the existing proxy select exactly this plan. # Reserve task-specific ports and let the existing proxy select exactly this plan.
sockets = [socket.socket() for _ in range(2)] sockets = [socket.socket() for _ in range(2)]
@@ -225,8 +268,10 @@ def prepare(scope, directory, config):
proxy.write_bytes(base64.b64decode(config['proxySource'])) proxy.write_bytes(base64.b64decode(config['proxySource']))
ip = consumer['NetworkSettings']['Networks']['firstleg-test-network']['IPAddress'] ip = consumer['NetworkSettings']['Networks']['firstleg-test-network']['IPAddress']
log = open(directory / 'proxy-output.log', 'wb') log = open(directory / 'proxy-output.log', 'wb')
upstream = ':9202/internal/firstleg/v1/container-events' if unload(config) else ':9303/internal/lastleg/events' upstream = ':9202/internal/firstleg/v1/container-events' if reverse_delivery(config) else ':9303/internal/lastleg/events'
selector = ['--event-type', 'OverseasContainerUnloaded', '--container-booking-id', scope['containerBookingId']] if unload(config) else ['--event-type', 'FirstLegLoadingPlanCompleted', '--loading-plan-id', scope['loadingPlanId']] selector = [] if delivery(config) else ['--event-type', 'OverseasContainerUnloaded', '--container-booking-id', scope['containerBookingId']] if unload(config) else ['--event-type', 'FirstLegLoadingPlanCompleted', '--loading-plan-id', scope['loadingPlanId']]
if delivery(config):
selector = ['--event-type', 'OverseasDeliveryCompleted', '--delivery-id', scope['deliveryId']]
process = subprocess.Popen(['python3', str(proxy), '--upstream', 'http://' + ip + upstream, *selector, process = subprocess.Popen(['python3', str(proxy), '--upstream', 'http://' + ip + upstream, *selector,
'--bind', '0.0.0.0', '--port', str(ports[0]), '--control-port', str(ports[1]), '--bind', '0.0.0.0', '--port', str(ports[0]), '--control-port', str(ports[1]),
'--log', str(directory / 'proxy-evidence.jsonl')], '--log', str(directory / 'proxy-evidence.jsonl')],
@@ -248,7 +293,7 @@ def activate(scope, directory, state):
setting, direct = target_setting(state) setting, direct = target_setting(state)
if original['Id'] != state['originalId'] or original['Config']['Image'] != state['image'] or env_of(original).get(setting) != direct: if original['Id'] != state['originalId'] or original['Config']['Image'] != state['image'] or env_of(original).get(setting) != direct:
raise RuntimeError('CONCURRENT_PRODUCER_CHANGE') raise RuntimeError('CONCURRENT_PRODUCER_CHANGE')
if unload(state) and (original['Image'] != state['originalImageId'] or fingerprint(original) != state['originalFingerprint']): if reverse_delivery(state) and (original['Image'] != state['originalImageId'] or fingerprint(original) != state['originalFingerprint']):
raise RuntimeError('CONCURRENT_PRODUCER_CHANGE') raise RuntimeError('CONCURRENT_PRODUCER_CHANGE')
body = copy.deepcopy(state['originalBody']) body = copy.deepcopy(state['originalBody'])
e = env_of(original) e = env_of(original)
@@ -270,7 +315,7 @@ def recover(scope, directory, state):
pinned = control(state) pinned = control(state)
if pinned['eventId'] != row['eventId'] or pinned['payloadHash'] != row['payloadHash']: if pinned['eventId'] != row['eventId'] or pinned['payloadHash'] != row['payloadHash']:
raise RuntimeError('PINNED_EVENT_MISMATCH') raise RuntimeError('PINNED_EVENT_MISMATCH')
state['beforeRetry'] = {**row, **({'container': container_facts(scope)} if unload(state) else {'businessOrderIds': business_ids(scope)})} state['beforeRetry'] = {**row, **business_snapshot(scope, state, row)}
private_write(directory / 'state.json', state) private_write(directory / 'state.json', state)
control(state, 'PASS') control(state, 'PASS')
container = inspect(producer_name(state)) container = inspect(producer_name(state))
@@ -278,7 +323,7 @@ def recover(scope, directory, state):
ip = container['NetworkSettings']['Networks']['firstleg-test-network']['IPAddress'] ip = container['NetworkSettings']['Networks']['firstleg-test-network']['IPAddress']
body = {'requestId': state['operationsRequestId'], 'operator': 'admin-full-test', body = {'requestId': state['operationsRequestId'], 'operator': 'admin-full-test',
'reason': scope['marker'] + ': scoped ACK-loss recovery', 'expectedPayloadHash': row['payloadHash']} 'reason': scope['marker'] + ': scoped ACK-loss recovery', 'expectedPayloadHash': row['payloadHash']}
endpoint = ':9303/internal/lastleg/event-operations/' if unload(state) else ':9202/internal/firstleg/event-operations/' endpoint = ':9303/internal/lastleg/event-operations/' if reverse_delivery(state) else ':9202/internal/firstleg/event-operations/'
url = 'http://' + ip + endpoint + row['entryId'] + '/retry' url = 'http://' + ip + endpoint + row['entryId'] + '/retry'
request = urllib.request.Request(url, json.dumps(body).encode(), request = urllib.request.Request(url, json.dumps(body).encode(),
{'X-Event-Operations-Token': e['EVENT_OPERATIONS_TOKEN'], 'Content-Type': 'application/json'}) {'X-Event-Operations-Token': e['EVENT_OPERATIONS_TOKEN'], 'Content-Type': 'application/json'})
@@ -295,7 +340,7 @@ def restore(directory, state):
try: try:
control(state, 'PASS') control(state, 'PASS')
except Exception: except Exception:
if not unload(state): if not reverse_delivery(state):
raise raise
# A dead proxy must not prevent restoring the producer's direct target. # A dead proxy must not prevent restoring the producer's direct target.
if state['prepared']: if state['prepared']:
@@ -309,8 +354,8 @@ def restore(directory, state):
state['prepared'] = False state['prepared'] = False
private_write(directory / 'state.json', state) private_write(directory / 'state.json', state)
return {'restoreStarted': True} return {'restoreStarted': True}
if current['Id'] == state['originalId'] or (unload(state) and current['Image'] == state['originalImageId'] and fingerprint(current) == state['originalFingerprint']): if current['Id'] == state['originalId'] or (reverse_delivery(state) and current['Image'] == state['originalImageId'] and fingerprint(current) == state['originalFingerprint']):
if unload(state) and not current['State'].get('Running'): if reverse_delivery(state) and not current['State'].get('Running'):
docker('POST', '/containers/' + current['Id'] + '/start') docker('POST', '/containers/' + current['Id'] + '/start')
state['prepared'] = False state['prepared'] = False
private_write(directory / 'state.json', state) private_write(directory / 'state.json', state)
@@ -330,15 +375,15 @@ def proof(scope, state):
event_id, entry_id = row['eventId'], row['entryId'] event_id, entry_id = row['eventId'], row['entryId']
if not re.fullmatch(r'[0-9a-fA-F-]{36}|[0-9A-HJKMNP-TV-Z]{26}', event_id) or not re.fullmatch(r'[1-9][0-9]{0,19}', entry_id): if not re.fullmatch(r'[0-9a-fA-F-]{36}|[0-9A-HJKMNP-TV-Z]{26}', event_id) or not re.fullmatch(r'[1-9][0-9]{0,19}', entry_id):
raise RuntimeError('INVALID_EVENT_IDENTITY') raise RuntimeError('INVALID_EVENT_IDENTITY')
event_type = 'OverseasContainerUnloaded' if unload(state) else 'FirstLegLoadingPlanCompleted' event_type = 'OverseasDeliveryCompleted' if delivery(state) else 'OverseasContainerUnloaded' if unload(state) else 'FirstLegLoadingPlanCompleted'
attempts = read_sql(inspect(producer_name(state)), attempts = read_sql(inspect(producer_name(state)),
"SELECT JSON_OBJECT('attemptId',attempt_id,'serviceName',service_name,'entryId',entry_id,'eventId',event_id,'targetName',target_name,'outcome',outcome) FROM event_delivery_attempt WHERE entry_id='" "SELECT JSON_OBJECT('attemptId',attempt_id,'serviceName',service_name,'entryId',entry_id,'eventId',event_id,'targetName',target_name,'outcome',outcome) FROM event_delivery_attempt WHERE entry_id='"
+ entry_id + "' AND event_type='" + event_type + "' ORDER BY started_at,attempt_id;") + entry_id + "' AND event_type='" + event_type + "' ORDER BY started_at,attempt_id;")
audit = read_sql(inspect(producer_name(state)), audit = read_sql(inspect(producer_name(state)),
"SELECT JSON_OBJECT('requestId',request_id,'entryId',entry_id,'eventId',event_id,'payloadHash',payload_hash,'previousFailures',previous_failures) FROM event_delivery_retry_audit WHERE request_id='" "SELECT JSON_OBJECT('requestId',request_id,'entryId',entry_id,'eventId',event_id,'payloadHash',payload_hash,'previousFailures',previous_failures) FROM event_delivery_retry_audit WHERE request_id='"
+ state['operationsRequestId'] + "';" if not unload(state) else + state['operationsRequestId'] + "';" if not reverse_delivery(state) else
"SELECT JSON_OBJECT('requestId',request_id,'entryId',entry_id,'eventId',event_id,'payloadHash',payload_hash,'previousFailures',previous_failures) FROM event_delivery_retry_audit WHERE entry_id='" + entry_id + "' AND event_type='OverseasContainerUnloaded';") "SELECT JSON_OBJECT('requestId',request_id,'entryId',entry_id,'eventId',event_id,'payloadHash',payload_hash,'previousFailures',previous_failures) FROM event_delivery_retry_audit WHERE entry_id='" + entry_id + "' AND event_type='" + event_type + "';")
if unload(state): if reverse_delivery(state):
consumers = read_sql(inspect('firstleg-test-business'), consumers = read_sql(inspect('firstleg-test-business'),
"SELECT JSON_OBJECT('eventId',event_id,'payloadHash',payload_hash,'status',status,'eventType',event_type,'aggregateId',aggregate_id,'payload',JSON_EXTRACT(payload_json,'$')) FROM fl_container_event_inbox WHERE event_id='" + event_id + "';") "SELECT JSON_OBJECT('eventId',event_id,'payloadHash',payload_hash,'status',status,'eventType',event_type,'aggregateId',aggregate_id,'payload',JSON_EXTRACT(payload_json,'$')) FROM fl_container_event_inbox WHERE event_id='" + event_id + "';")
else: else:
@@ -348,7 +393,7 @@ def proof(scope, state):
consumer = {**(consumers[0] if len(consumers) == 1 else {}), 'count': len(consumers)} consumer = {**(consumers[0] if len(consumers) == 1 else {}), 'count': len(consumers)}
result = {**scope, 'readOnly': True, 'producer': row, 'consumer': consumer, 'attempts': attempts, result = {**scope, 'readOnly': True, 'producer': row, 'consumer': consumer, 'attempts': attempts,
'proxyRecords': control(state)['records'], 'beforeRetry': state['beforeRetry'], 'proxyRecords': control(state)['records'], 'beforeRetry': state['beforeRetry'],
'afterRetry': {**row, **({'container': container_facts(scope)} if unload(state) else {'businessOrderIds': business_ids(scope)})}, 'afterRetry': {**row, **business_snapshot(scope, state, row)},
'retryAudit': audit[0] if len(audit) == 1 else None, 'retryAuditCount': len(audit), 'retryRequestId': state['operationsRequestId'], 'restored': False} 'retryAudit': audit[0] if len(audit) == 1 else None, 'retryAuditCount': len(audit), 'retryRequestId': state['operationsRequestId'], 'restored': False}
private_write(scope_directory(scope, state.get('direction', 'LOADING')) / 'proof.json', result) private_write(scope_directory(scope, state.get('direction', 'LOADING')) / 'proof.json', result)
return result return result
@@ -363,14 +408,14 @@ def finish(directory, state):
raise RuntimeError('RESTORED_SERVICE_NOT_HEALTHY') raise RuntimeError('RESTORED_SERVICE_NOT_HEALTHY')
if sorted(current['Config']['Env']) != sorted(state['originalBody']['Env']): if sorted(current['Config']['Env']) != sorted(state['originalBody']['Env']):
raise RuntimeError('RESTORED_CONFIGURATION_MISMATCH') raise RuntimeError('RESTORED_CONFIGURATION_MISMATCH')
if unload(state) and (current['Image'] != state['originalImageId'] or fingerprint(current) != state['originalFingerprint']): if reverse_delivery(state) and (current['Image'] != state['originalImageId'] or fingerprint(current) != state['originalFingerprint']):
raise RuntimeError('RESTORED_CONFIGURATION_MISMATCH') raise RuntimeError('RESTORED_CONFIGURATION_MISMATCH')
cmdline = Path('/proc/' + str(state['proxyPid']) + '/cmdline') cmdline = Path('/proc/' + str(state['proxyPid']) + '/cmdline')
if cmdline.exists(): if cmdline.exists():
if str(directory / 'proxy.py').encode() not in cmdline.read_bytes().split(b'\x00'): if str(directory / 'proxy.py').encode() not in cmdline.read_bytes().split(b'\x00'):
raise RuntimeError('PROXY_PROCESS_OWNERSHIP_MISMATCH') raise RuntimeError('PROXY_PROCESS_OWNERSHIP_MISMATCH')
os.kill(state['proxyPid'], signal.SIGTERM) os.kill(state['proxyPid'], signal.SIGTERM)
if unload(state): if reverse_delivery(state):
for _ in range(50): for _ in range(50):
if not cmdline.exists() or not cmdline.read_bytes(): if not cmdline.exists() or not cmdline.read_bytes():
break break
@@ -378,7 +423,7 @@ def finish(directory, state):
else: else:
raise RuntimeError('PROXY_NOT_STOPPED') raise RuntimeError('PROXY_NOT_STOPPED')
result = {'restored': True} result = {'restored': True}
if unload(state): if reverse_delivery(state):
result.update(healthy=True, proxyStopped=True, producerName=producer_name(state), result.update(healthy=True, proxyStopped=True, producerName=producer_name(state),
originalImageId=state['originalImageId'], currentImageId=current['Image'], originalImageId=state['originalImageId'], currentImageId=current['Image'],
originalFingerprint=state['originalFingerprint'], currentFingerprint=fingerprint(current)) originalFingerprint=state['originalFingerprint'], currentFingerprint=fingerprint(current))