From 3afb3bcfd18238c133605153897601d608d0a970 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 04:52:38 +0800 Subject: [PATCH] fix(test): stream resource fault configuration through stdin --- scripts/README.md | 2 ++ .../inbound-plan-resource-fault-executor.mjs | 6 ++++- ...ound-plan-resource-fault-executor.test.mjs | 27 +++++++++++++++++++ .../inbound-plan-resource-fault-runtime.py | 14 ++++++++-- ...bound-plan-resource-fault-runtime.test.mjs | 18 +++++++++++++ 5 files changed, 64 insertions(+), 3 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index d651a99ce..b36624fb6 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -4,6 +4,8 @@ F33 分阶段助手的组合换标顺序为 `unit → unit-carton → bind`:只有完整逐箱作业事实后才可绑定实物托盘,不用默认数量补齐缺失事实。已明确拒绝的绑定仅允许当前已冻结原票、原批次、原命令摘要的 `F33_BIND_A0409_AFTER_UNIT` 恢复,必须同时核对不可变失败journal、完整回滚证明和新作业事实/原资源版本,恢复写入独立journal,不覆盖原失败。未知结果、其他拒绝和其他阶段仍禁止自动重试;响应message脱敏留存。该受限恢复只用于原TEST验收,不放宽生产接口或变更库存规则。 +远端执行器仅通过标准输入传递有界UTF-8 JSON配置,不将包含源码的配置放入进程参数,避免Linux单参数长度限制;输入上限1MiB,运行代码参数小于64KiB。进程启动失败和stdin写入结果未知分别返回安全错误码;仅解析一条结构化结果,不输出完整配置、底层异常正文或凭据。stdin结果未知仍先按原marker核对状态,不能自动重试prepare或容器变更。 + `inbound-plan-resource-fault-proxy.py --config <权限0600的私有配置>` 是柜级入仓计划资源确认的 TEST 定向故障工具。导入不执行,运行才监听业务端口与回环控制端口;复用 `order-submit-dependency-fault-proxy.py` 的 HTTP 传输和服务,配置必须显式 `environment=TEST`,冻结柜ID、海外仓ID、按当前协调器JSON字符串算法计算的柜批次前缀、Lastleg来源IP和内部身份。默认直通;持有私有控制凭据的 `CONFIRM_ONCE` 最长60秒,只让该柜精确 `/internal/v1/warehouse-pallet-batches/confirm` POST 返回一次503,随后自动直通。其他柜、预留/查询、错身份及不匹配请求保留原请求响应。凭据只读私有配置,脱敏证据写同目录 `proxy-evidence.jsonl`,不保存正文或令牌。工具不修改SQL或Docker,不自行重试业务或恢复服务路由;运行者须先安排独占故障窗口、记录并恢复原Lastleg配置/镜像及健康、停止本代理,结果未知先核对,不盲目重启。`inbound-plan-resource-fault-proxy.test.mjs` 为离线范围、单次并发、TTL与脱敏测试;工具检查通过不等于已执行真实故障与页面恢复。 `partial-loading-actual-test-env.mjs` 是 F21/F22 两柜实装与部分装柜的 TEST 专项入口,默认无参数或 `--plan` 零网络。使用显式授权 TEST 环境文件,匹配 Firstleg/触发器就绪并开放独占样本窗口后,才可传 `--execute --environment=TEST --run-id=PARTIAL-ACTUAL-<新批次> --fixture=NEW`,或用 `--fixture=` 提供本轮已核对来源。新准备只走客户资格、下单、测量、装柜正式接口;A 覆盖混合客户/税制、短拣和跳过,B 的全部实装行均为 PARTIAL、退税来源全部跳过。检查实际数量/指标、报关范围及同命令重放;合成图片只证明 HTTP,不是现场证据。报告保存至 `reports/partial-loading-actual-PARTIAL-ACTUAL-<批次>/`,先记录写入意图,拒绝覆盖;失败保留原样本和报告,未知结果不得自动重造或重试。仅在明确续跑授权和原快照核对后接续,退出自己的会话;不执行 SQL/部署、船节点或历史回填,不代替事件 ACK/下游库存、完整报关文件和页面/设备验收。直接测试 `partial-loading-actual-test-env.test.mjs`。 diff --git a/scripts/inbound-plan-resource-fault-executor.mjs b/scripts/inbound-plan-resource-fault-executor.mjs index b02e30992..3769e0f97 100644 --- a/scripts/inbound-plan-resource-fault-executor.mjs +++ b/scripts/inbound-plan-resource-fault-executor.mjs @@ -44,8 +44,12 @@ export function renderInboundResourceAction(action, scope, agent, extra={}) { routingFor(action,extra); if(action==='mode')need(['PASS','CONFIRM_ONCE'].includes(extra.mode)&&(extra.mode==='PASS'||Number.isInteger(extra.ttl)&&extra.ttl>=1&&extra.ttl<=60),'INVALID_MODE_OR_TTL'); const config={...extra,action,scope,baseSource,commonSource,...(action==='prepare'?{proxySource,httpProxySource}:{})}; + const configJson=JSON.stringify(config); + need(Buffer.byteLength(configJson,'utf8')<=1024*1024,'RUNTIME_STDIN_CONFIG_TOO_LARGE'); + need(runtime.length<65536,'RUNTIME_CODE_ARG_TOO_LARGE'); const literal=text=>'['+encode(text).match(/.{1,4000}/g).map(part=>`'${part}'`).join(',')+"].join('')"; - const groovy=`def p=new ProcessBuilder(['python3','-c',new String(java.util.Base64.decoder.decode(${literal(runtime)}),'UTF-8'),${literal(JSON.stringify(config))}]).redirectErrorStream(true).start();def out=p.inputStream.getText('UTF-8');p.waitFor();print(out)`; + const safeResult=(code,stage)=>`print('INBOUND_RESOURCE_JSON='+groovy.json.JsonOutput.toJson([ok:false,error:'${code}',diagnostic:[stage:'${stage}',errorType:'IOException']]))`; + const groovy=`def p;try{p=new ProcessBuilder(['python3','-c',new String(java.util.Base64.decoder.decode(${literal(runtime)}),'UTF-8')]).redirectErrorStream(true).start()}catch(java.io.IOException e){${safeResult('REMOTE_PROCESS_START_FAILED','REMOTE_PROCESS_START')};return};try{p.outputStream.withCloseable{s->s.write(java.util.Base64.decoder.decode(${literal(configJson)}));s.flush()}}catch(java.io.IOException e){${safeResult('RUNTIME_STDIN_OUTCOME_UNKNOWN_USE_STATUS','REMOTE_PROCESS_STDIN')};return};def out=p.inputStream.getText('UTF-8');p.waitFor();def rows=out.readLines().findAll{it.startsWith('INBOUND_RESOURCE_JSON=')};if(rows.size()==1){print(rows[0])}else{print('INBOUND_RESOURCE_JSON='+groovy.json.JsonOutput.toJson([ok:false,error:'AMBIGUOUS_RESPONSE_NO_AUTO_RETRY']))}`; 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(${literal(groovy)}),'UTF-8'),c.channel))`; } export function createInboundResourceRuntime(env, scope, fetcher=globalThis.fetch) { diff --git a/scripts/inbound-plan-resource-fault-executor.test.mjs b/scripts/inbound-plan-resource-fault-executor.test.mjs index bb76d2490..327476387 100644 --- a/scripts/inbound-plan-resource-fault-executor.test.mjs +++ b/scripts/inbound-plan-resource-fault-executor.test.mjs @@ -29,6 +29,27 @@ test('renderer chunks complete payload and carries both proxy modules and review assert.deepEqual(config.scope,scope);assert.equal(config.action,'prepare'); for(const key of ['proxySource','httpProxySource','commonSource','baseSource'])assert.ok(config[key].length>100); const runtime=Buffer.from(embedded[0].join(''),'base64').toString();assert.ok(runtime.includes('firstleg-test-lastleg')); + assert.match(groovy,/new ProcessBuilder\(\['python3','-c',new String\(.+?,'UTF-8'\)\]\)/s); + assert.match(groovy,/p\.outputStream\.withCloseable\{s->s\.write\(java\.util\.Base64\.decoder\.decode\(/); + assert.ok(Buffer.byteLength(runtime)<65536); + assert.ok(embedded[1].join('').length>131072,'prepare reproduces the old over-limit configuration argv'); + assert.ok(runtime.includes('main(read_stdin_config())')); + assert.ok(!runtime.includes('base64.b64decode(sys.argv[1])')); +}); +test('complete prepare configuration travels over stdin to actual Python with bounded argv and no config echo',()=>{ + const rendered=renderInboundResourceAction('prepare',scope,'test-agent',{routing,expectedImageId:'sha256:'+'c'.repeat(64)}); + const groovy=Buffer.from(arrays(rendered)[0].join(''),'base64').toString();const embedded=arrays(groovy); + const runtime=Buffer.from(embedded[0].join(''),'base64').toString(); + const config=JSON.parse(Buffer.from(embedded[1].join(''),'base64').toString()); + // Reject scope before locks/Docker/network. JSON still must consume the complete four-source body. + config.scope.environment='PROD';const input=JSON.stringify(config); + const argv=['-c',runtime];assert.equal(argv.length,2);assert.ok(argv.every(a=>Buffer.byteLength(a)<65536)); + assert.ok(Buffer.byteLength(input)>100000);for(const key of ['baseSource','commonSource','proxySource','httpProxySource'])assert.ok(config[key].length>100); + const result=spawnSync(process.env.PYTHON||'python',argv,{input,encoding:'utf8',env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'}}); + assert.equal(result.status,0,result.stderr||result.error?.message);assert.equal(result.stderr,''); + const rows=result.stdout.trim().split(/\r?\n/);assert.equal(rows.length,1);assert.ok(rows[0].startsWith('INBOUND_RESOURCE_JSON=')); + const value=JSON.parse(rows[0].slice('INBOUND_RESOURCE_JSON='.length));assert.equal(value.error,'EXPLICIT_TEST_SCOPE_REQUIRED'); + assert.equal(value.diagnostic.stage,'READ_STDIN_CONFIG');assert.ok(!result.stdout.includes(config.baseSource.slice(0,80)));assert.ok(!result.stdout.includes('upstreams')); }); test('origin/schema guards reject credential embedding, wrong imports and unbounded fault windows',()=>{ for(const change of [r=>r.password='secret',r=>r.upstreams.IAM='http://offline.invalid',r=>r.nacos.baseUrl='http://user:secret@offline.invalid',r=>r.nacos.configPath='/fallback']){ @@ -46,6 +67,12 @@ test('transport unknown, duplicate result and malformed result each fail after e } assert.throws(()=>createInboundResourceRuntime({...env,CHONGCHONG_ENV:'prod'},scope)); }); +test('structured remote process-start/stdin errors remain single results and never echo private payloads',async()=>{ + for(const [code,stage] of [['REMOTE_PROCESS_START_FAILED','REMOTE_PROCESS_START'],['RUNTIME_STDIN_OUTCOME_UNKNOWN_USE_STATUS','REMOTE_PROCESS_STDIN']]){ + let calls=0;const runtime=createInboundResourceRuntime(env,scope,async()=>{calls++;return{ok:true,text:async()=> 'INBOUND_RESOURCE_JSON='+JSON.stringify({ok:false,error:code,diagnostic:{stage,errorType:'IOException'}})}}); + await assert.rejects(runtime('prepare',{routing,expectedImageId:'sha256:'+'c'.repeat(64)}),error=>{assert.equal(error.message,code);assert.deepEqual(error.runtimeDiagnostic,{stage,errorType:'IOException'});return true;});assert.equal(calls,1); + } +}); test('remote error preserves safe stage/type/status and strips untrusted diagnostic fields',async()=>{ const runtime=createInboundResourceRuntime(env,scope,async()=>({ok:true,text:async()=> 'INBOUND_RESOURCE_JSON='+JSON.stringify({ok:false,error:'NACOS_CONFIG_READ_HTTP_404',diagnostic:{stage:'NACOS_CONFIG_READ',errorType:'HTTPError',httpStatus:404,token:'must-never-escape',body:'secret',url:'http://private.invalid'}})})); await assert.rejects(runtime('preflight',{routing}),error=>{ diff --git a/scripts/inbound-plan-resource-fault-runtime.py b/scripts/inbound-plan-resource-fault-runtime.py index d1d9f4d2f..d20a4625f 100644 --- a/scripts/inbound-plan-resource-fault-runtime.py +++ b/scripts/inbound-plan-resource-fault-runtime.py @@ -229,6 +229,7 @@ def execute(config): 'sourceSha256': source_hash, 'runtimeChanged': False, 'upstreamImageId': target['Image'], 'upstreamFingerprint': upstream_fingerprint} if action == 'prepare': return runtime.prepare(config) + STAGE = 'RUNTIME_STATE_READ' state = runtime.load() if action == 'status': result = runtime.summary(state) @@ -255,8 +256,8 @@ def execute(config): def main(config): - import fcntl validate_scope(config['scope']) + import fcntl file = Path('/tmp') / ('chongchong-inbound-resource-' + config['scope']['marker'] + '.action.lock') if file.is_symlink(): raise RuntimeError('UNSAFE_ACTION_LOCK') fd = os.open(file, os.O_RDWR | os.O_CREAT, 0o600) @@ -267,7 +268,16 @@ def main(config): finally: os.close(fd) +def read_stdin_config(stream=None): + """One bounded UTF-8 JSON stdin protocol; configuration is never argv.""" + global STAGE + STAGE = 'READ_STDIN_CONFIG' + raw = (sys.stdin.buffer if stream is None else stream).read(1024 * 1024 + 1) + if len(raw) > 1024 * 1024: raise RuntimeError('RUNTIME_STDIN_CONFIG_TOO_LARGE') + return json.loads(raw.decode('utf-8')) + + if __name__ == '__main__': - try: print('INBOUND_RESOURCE_JSON=' + json.dumps({'ok': True, 'data': main(json.loads(base64.b64decode(sys.argv[1])))})) + try: print('INBOUND_RESOURCE_JSON=' + json.dumps({'ok': True, 'data': main(read_stdin_config())})) except Exception as error: print('INBOUND_RESOURCE_JSON=' + json.dumps(safe_diagnostic(error))) diff --git a/scripts/inbound-plan-resource-fault-runtime.test.mjs b/scripts/inbound-plan-resource-fault-runtime.test.mjs index 71e7e5e78..6f20e27b9 100644 --- a/scripts/inbound-plan-resource-fault-runtime.test.mjs +++ b/scripts/inbound-plan-resource-fault-runtime.test.mjs @@ -4,8 +4,16 @@ import {spawnSync} from 'node:child_process'; import {fileURLToPath} from 'node:url'; const script=String.raw` import importlib.util,sys,json,hashlib,copy,urllib.error +import io from pathlib import Path spec=importlib.util.spec_from_file_location('resource_runtime',sys.argv[1]);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +payload={'scope':{'marker':'stdin'},'complete':'x'*200000} +assert m.read_stdin_config(io.BytesIO(json.dumps(payload).encode()))==payload +try:m.read_stdin_config(io.BytesIO(b'x'*(1024*1024+1)));raise AssertionError('unbounded config accepted') +except RuntimeError as e:assert str(e)=='RUNTIME_STDIN_CONFIG_TOO_LARGE' +try:m.read_stdin_config(io.BytesIO(b'{"private":"secret"'));raise AssertionError('partial JSON accepted') +except json.JSONDecodeError as e: + d=m.safe_diagnostic(e);assert d['diagnostic']['stage']=='READ_STDIN_CONFIG';assert 'secret' not in json.dumps(d) m.STAGE='NACOS_SOURCE_READ' for endpoint,stage in [('/nacos/v3/auth/user/login','NACOS_LOGIN'),('/nacos/v3/admin/cs/config','NACOS_CONFIG_READ')]: d=m.safe_diagnostic(urllib.error.HTTPError('http://user:password@private.invalid'+endpoint+'?token=secret',404,'secret body',{},None)) @@ -59,6 +67,16 @@ for key,value in [('environment','PROD'),('warehouseId','0'),('batchPrefixHash', bad=scope|{key:value} try:m.validate_scope(bad);raise AssertionError('invalid scope accepted') except RuntimeError:pass +import base64 +original_factory=m.runtime_class +class MissingState: + def __init__(self,*_):pass + def load(self):raise FileNotFoundError('private-state-path-must-not-escape') +m.runtime_class=lambda _:(MissingState,{'common_tools':lambda _: {}}) +try:m.execute({'action':'status','scope':scope,'baseSource':base64.b64encode(b'offline').decode(),'commonSource':base64.b64encode(b'offline').decode()});raise AssertionError('missing state accepted') +except FileNotFoundError as e: + d=m.safe_diagnostic(e);assert d['diagnostic']=={'stage':'RUNTIME_STATE_READ','errorType':'FileNotFoundError'};assert 'private' not in json.dumps(d) +m.runtime_class=original_factory print('OFFLINE_RUNTIME_PASS') `; test('reviewed Docker methods retain exact target, one-property overlay, journal uncertainty and restore-before-cleanup',()=>{