diff --git a/docs/maintenance/NODE_BATCH_RUNTIME.md b/docs/maintenance/NODE_BATCH_RUNTIME.md index 90f555b..6e9d6c0 100644 --- a/docs/maintenance/NODE_BATCH_RUNTIME.md +++ b/docs/maintenance/NODE_BATCH_RUNTIME.md @@ -18,13 +18,13 @@ 先按CoDocs确认VPS、白色机访问路线、正式组、展示编号及资源冲突。Python执行环境使用`requirements.txt`的Paramiko 5.0.0;配置与执行必须使用同一解释器。安装来源限官方包源/已审核环境,不临时导入任务目录中的未知transport模块。 -生产前先提交并推送源码,再生成、推送和部署独立运维Git版本:保留原安装脚本,加入`panel-acceptance.php`及其`operations.php`,还有stage/promote/rollback/readiness/verify-node入口;面板、网关和目标节点的对应运维目录必须为计划中完整`provision_commit`且无工作区差异。配置文件和凭据不入Git。独立快照由正式`bundle-ops.py`生成,保持基础运维树、工作目录和真实Git索引不变,仅复制10个明确的已提交运维文件,拒绝覆盖已有分支或bundle,不联网: +生产前先提交并推送源码,再生成、推送和部署独立运维Git版本:保留原安装脚本,加入`panel-acceptance.php`及其`operations.php`,还有stage/promote/rollback/readiness/verify-node入口;面板、网关和目标节点的对应运维目录必须为计划中完整`provision_commit`且无工作区差异。配置文件和凭据不入Git。独立快照由正式`bundle-ops.py`生成,保持基础运维树、工作目录和真实Git索引不变,仅复制10个明确的已提交运维文件,默认拒绝已有分支或bundle,不联网: ```sh python3 scripts/node-onboarding/bundle-ops.py --source FULL_SOURCE_COMMIT --base FULL_OPS_BASE_COMMIT --branch codex/node-tools-BATCH --bundle /private/task/provision.bundle ``` -随后按已授权任务推送返回的运维分支、审核commit/bundle摘要,再经白色机部署到明确版本。当前源码/运维快照的提交推送与远端部署分别报告;本轮不自动切换任何生产应用或旧任务的运维目录。 +同一运维分支的后续修订只有显式`--advance`才允许,且`--base`必须精确等于当前分支头;新提交以该base为父并通过旧值比较更新,仅快进,不强推或覆盖并行修改。bundle仍要求新路径。随后按已授权任务推送返回的运维分支、审核commit/bundle摘要,再经白色机部署到明确版本。当前源码/运维快照的提交推送与远端部署分别报告;本轮不自动切换任何生产应用或旧任务的运维目录。 适配器在stage前检查本地依赖、远端Git版本及工具、正式组和账号、节点既有配置、独立端口、原服务、证书归属与钩子。既有证书无本任务owner、全局证书钩子或目标证书的额外pre/post钩子均停止。缺依赖须在准备阶段解决,不先创建一批面板对象后再临时安装工具。 @@ -92,9 +92,9 @@ SSH会话仅在本次进程、同一工作线程内复用,线程不共用可 ## 动态验收和测试用户 -准备阶段先按正式组、banned、额度和有效期选既有账号,不把管理员身份自动排除;检查真实`ServerService::getAvailableServers`及`getAvailableUsers`,包括插件过滤。没有可用账号时自动创建本任务测试用户:非管理员、100MiB、1小时、1设备、无订单/套餐改写及通知消息。私有journal在DB提交前记录身份,丢失回包时恢复同一用户,不重复创建或接管同名用户。 +准备阶段先按正式组、banned、额度和有效期选既有账号,优先管理员且最多检查20个候选,避免全表巡检客户凭据;检查真实`ServerService::getAvailableServers`及`getAvailableUsers`,包括插件过滤;候选被实际节点下发过滤时不算可用。未找到可用账号时自动创建本任务测试用户:非管理员、100MiB、1小时、1设备、无订单/套餐改写及通知消息。私有journal在DB提交前记录身份,丢失回包时恢复同一用户,不重复创建或接管同名用户。 -新节点仍沿用各自隔离QA→严格TLS/出口/上报→独立助手按精确报告审核→append开放并禁用QA。全部本组候选开放后,正式验收按账号**当前实际应获得的集合**生成完整快照,与客户端完整订阅逐条校验,并逐条实际联网。集合绑定节点ID、标签、端点、SNI、独立预期出口/国家;验收后再取一次集合,增加、删除或改变任一项均阻止旧报告通过,不固定节点数。旧节点同样必须覆盖。 +新节点仍沿用各自隔离QA→严格TLS/出口/上报→独立助手按精确报告审核→append开放并禁用QA。适配器直接生成`key-evidence.json`,包含原始报告字节SHA256、请求摘要、QA身份、报告时间及最新在线/双向上报状态,600原子保存,不再需要任务专用采集脚本。助手完成独立审核后,主协调者在同目录记录`key-reviewed.json`:`request_sha256`、`report_sha256`、`qa_user_id`、`verified_at`必须与该报告完全相同;未审核不得自动填批准文件。全部本组候选开放后,正式验收按账号**当前实际应获得的集合**生成完整快照,与客户端完整订阅逐条校验,并逐条实际联网。集合绑定节点ID、标签、端点、SNI、独立预期出口/国家;验收后再取一次集合,增加、删除或改变任一项均阻止旧报告通过,不固定节点数。旧节点同样必须覆盖。 每组正式客户端串行运行,保留完整订阅DNS/路由,只给诊断目的地固定出站。新建测试用户可能等待节点拉取用户列表;只对通用客户端运行失败最多做3轮诊断读测试,间隔15秒且共用900秒预算。结构、TLS或出口断言错误不重试;不重放生产写入、不另建用户来绕过失败。证据绑定完整计划摘要、节点集合摘要、原始报告及QA拒绝文件SHA256、QA实际ID、403、banned、实际下发排除及900秒时效。任一缺项或摘要/集合变化失败。旧节点关键配置、全部套餐、原用户关联和订阅地址按批次原始基线核对,网关容器ID/启动时间/重启数再次核对。 @@ -109,3 +109,11 @@ preflight/publish/audit始终现场执行。只有先前状态为done、收到 ## 验证范围 新增Python测试覆盖动态1/2/7/23节点、集合变化/漏旧节点、出口/TLS/QA身份/摘要/时效、白色机跳转及严格密钥策略、线程会话复用/断线、依赖续跑及未知/过期状态、原服务精确调查回执、远端脚本与PHP构造。PHP隔离测试使用真实Eloquent/SQLite和ServerService,覆盖自动受限用户、既有管理员复用、真实下发、无效账号、journal恢复、关闭后新租约、归属漂移、插件排除及失败收尾;不加载.env/应用provider/Redis/HTTP。生产停机、失败回退、远端真实会话复用性能及十节点耗时尚未演练,不承诺固定提速倍数。 + +隔离回归入口(不使用项目默认PHPUnit配置): + +```sh +python3 -B -m unittest discover -s tests/node-onboarding -p 'test_*.py' -q +php vendor/bin/phpunit --no-configuration tests/node-onboarding/PanelChangeIsolatedTest.php +php vendor/bin/phpunit --no-configuration tests/node-onboarding/AcceptanceUserIsolatedTest.php +``` diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index c3a4f98..42d4c5a 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -8,7 +8,7 @@ 续跑保留全批前检及publish/audit现场关口,以请求/代码/当前依赖摘要和有效期复用已完成步骤,前驱变化向下游失效;事件输出替代重复状态轮询。原服务以unit/程序/配置/监听和进程信息区分实际冲突与待调查重启,原基线不覆盖。独立运维打包工具从已提交源码生成可追踪快照,保留工作区/真实索引及旧运维基础树;旧命令适配器及旧冻结任务文件不修改。 -本轮Python 30项测试通过;PHP新增9项/35断言及既有9项/65断言隔离测试通过,均未加载真实.env或联网。CoDocs全库离线检查通过,前次.obsidian权限问题已由用户处理,本轮未读写其配置正文。新版本尚未部署至远端或进行真实SSH/客户端性能演练,不能把上一轮三节点验收复用为新实现线上通过,也不承诺十节点耗时。准备/部署/执行与边界见[正式适配器专题](NODE_BATCH_RUNTIME.md);后续新批次默认按该入口执行。 +本轮Python 32项测试通过;PHP新增10项/38断言及既有9项/65断言隔离测试通过,均未加载真实.env或联网。CoDocs全库离线检查通过,前次.obsidian权限问题已由用户处理,本轮未读写其配置正文。新版本尚未部署至远端或进行真实SSH/客户端性能演练,不能把上一轮三节点验收复用为新实现线上通过,也不承诺十节点耗时。准备/部署/执行与边界见[正式适配器专题](NODE_BATCH_RUNTIME.md);后续新批次默认按该入口执行。实现源码已提交推送,独立运维分支`codex/node-runtime-20261017`已准备并经Git bundle校验;精确版本按该分支Git记录读取,仍未切换远端旧任务的运行目录。 ## 2026-10-17 三节点批量接入完成 diff --git a/scripts/node-onboarding/bundle-ops.py b/scripts/node-onboarding/bundle-ops.py index d06427c..e2ca423 100644 --- a/scripts/node-onboarding/bundle-ops.py +++ b/scripts/node-onboarding/bundle-ops.py @@ -13,7 +13,7 @@ FILES=('stage.php','promote.php','operations.php','rollback.php','readiness.php' 'verify-node.py','panel-acceptance.php','install-node.sh','renew-node.sh','dpl-node.service') -def build(repo,source,base,branch,bundle): +def build(repo,source,base,branch,bundle,advance=False): if not all(re.fullmatch('[0-9a-f]{40}',r) for r in (source,base)) or not re.fullmatch('codex/[A-Za-z0-9_/-]+',branch) or '..' in branch: raise ValueError('Explicit full commits and a new codex branch required') destination=Path(bundle) @@ -24,7 +24,11 @@ def build(repo,source,base,branch,bundle): return r.stdout git('cat-file','-e',source+'^{commit}');git('cat-file','-e',base+'^{commit}');git('check-ref-format','refs/heads/'+branch) exists=subprocess.run(['git','-C',str(repo),'show-ref','--verify','--quiet','refs/heads/'+branch]).returncode - if exists!=1:raise ValueError('Branch exists or outcome unknown; inspect it, do not overwrite') + expected_old='0'*40 + if exists==0 and advance: + expected_old=git('rev-parse','refs/heads/'+branch).decode().strip() + if expected_old!=base:raise ValueError('Advance requires the exact current branch head as reviewed base') + elif exists!=1:raise ValueError('Branch exists or outcome unknown; inspect it, do not overwrite') with tempfile.TemporaryDirectory(prefix='dpl-ops-index-',dir=destination.parent) as temp: env=dict(os.environ,GIT_INDEX_FILE=str(Path(temp)/'index')) git('read-tree',base,env=env) @@ -36,7 +40,7 @@ def build(repo,source,base,branch,bundle): git('update-index','--add','--cacheinfo',mode+','+sha+','+name,env=env) tree=git('write-tree',env=env).decode().strip() commit=git('commit-tree',tree,'-p',base,input=('DPL operations snapshot\n\nSource commit: '+source+'\n').encode()).decode().strip() - git('update-ref','refs/heads/'+branch,commit,'0'*40) + git('update-ref','refs/heads/'+branch,commit,expected_old) git('bundle','create',str(destination),'refs/heads/'+branch) destination.chmod(0o600) return {'commit':commit,'source_commit':source,'branch':branch,'bundle_sha256':hashlib.sha256(destination.read_bytes()).hexdigest(),'files':len(FILES),'network':False} @@ -46,9 +50,10 @@ def main(): p=argparse.ArgumentParser(description=__doc__) p.add_argument('--repo',default=str(Path(__file__).resolve().parents[2])) for k in ('source','base','branch','bundle'):p.add_argument('--'+k,required=True) + p.add_argument('--advance',action='store_true',help='Explicit fast-forward only from the exact --base current branch head') a=p.parse_args() try: - print(json.dumps(build(a.repo,a.source,a.base,a.branch,a.bundle)));return 0 + print(json.dumps(build(a.repo,a.source,a.base,a.branch,a.bundle,a.advance)));return 0 except Exception: print(json.dumps({'state':'blocked','reason':'Inspect source/base, branch and bundle outcome before retry; no network performed'}));return 2 diff --git a/scripts/node-onboarding/panel-acceptance.php b/scripts/node-onboarding/panel-acceptance.php index b09b86f..f922cdf 100644 --- a/scripts/node-onboarding/panel-acceptance.php +++ b/scripts/node-onboarding/panel-acceptance.php @@ -33,7 +33,13 @@ final class Acceptance { $j['id']=$u->id;$j['state']='active';if($apply)writeJson($path,$j); return ['state'=>'ready','id'=>$u->id,'owned'=>$j['owned']]; } - $u=User::where('group_id',$group)->where('banned',0)->whereRaw('u+dwhere(fn($q)=>$q->whereNull('expired_at')->orWhere('expired_at','>=',time()))->where('email','not like','node-qa-%')->where('email','not like','batch-acceptance-%')->orderBy('id')->first(); + $u=null; + $candidates=User::where('group_id',$group)->where('banned',0)->whereRaw('u+dwhere(fn($q)=>$q->whereNull('expired_at')->orWhere('expired_at','>=',time()))->where('email','not like','node-qa-%')->where('email','not like','batch-acceptance-%')->orderByDesc('is_admin')->orderBy('id')->limit(20)->get(); + foreach($candidates as $candidate){ + $effective=true; + foreach(ServerService::getAvailableServers($candidate) as $s)if(!ServerService::getAvailableUsers(Server::findOrFail($s['id']))->contains('id',$candidate->id)){$effective=false;break;} + if($effective){$u=$candidate;break;} + } if(!$apply)return ['state'=>$u?'existing':'would_create']; $j=['group'=>$group,'state'=>'prepared','owned'=>!$u,'email'=>$u?->email??'batch-acceptance-'.substr(hash('sha256',$dir),0,12).'-'.Str::lower(Str::random(8)).'@example.invalid','uuid'=>$u?->uuid??(string)Str::uuid(),'token'=>$u?->token??Str::random(32),'quota'=>104857600,'expires_at'=>time()+3600]; // Persist identity before commit. A lost response resumes the same row, never another user. diff --git a/scripts/node-onboarding/site_adapter.py b/scripts/node-onboarding/site_adapter.py index b569b66..962e8b4 100644 --- a/scripts/node-onboarding/site_adapter.py +++ b/scripts/node-onboarding/site_adapter.py @@ -220,6 +220,15 @@ for attempt in range(3 if source['owned'] else 1): # Persist only nonsecret, byte-bound results for coordinator/operator review. self.atomic_json(self.directory/('formal-group-'+str(group)+'.json'),{'snapshot':before,'report':json.loads(report),'qas':json.loads(qas),'binding':binding}) + def qa_evidence(self,n,raw,state): + # Bind the exact remote bytes, not a re-serialized nested report. Persist the latest + # online/traffic state so independent review never needs a separate task collector. + report=json.loads(raw) + self.atomic_json(self.directory/(n['key']+'-evidence.json'),{ + 'key':n['key'],'request_sha256':n['request_sha256'], + 'report_sha256':hashlib.sha256(raw).hexdigest(),'report':report, + 'panel':state,'collected_at':int(time.time())}) + @staticmethod def php_value(value): return "json_decode(base64_decode('"+base64.b64encode(json.dumps(value).encode()).decode()+"'),true)" @@ -376,20 +385,20 @@ for attempt in range(3 if source['owned'] else 1): existing=read(panel,B+'/qa-report.json') if existing: report=json.loads(existing) - if report.get('state')=='live_verified' and report.get('qa_user_id')==s['manifest']['test_user_id'] and 0<=time.time()-report.get('verified_at',0)<=900 and s['node_online'] and s['u']+s['d']>0: + if report.get('state')=='live_verified' and report.get('qa_user_id')==s['manifest']['test_user_id'] and 0<=time.time()-report.get('verified_at',0)<=900 and s['node_online'] and s['u']>0 and s['d']>0: + self.qa_evidence(n,existing,s) r=receipt('ready');r['valid_until']=min(r['valid_until'],report['verified_at']+900);return r transfer(panel,gateway,node);fetch_qa(gateway) execute(gateway,'python3 '+OPS+'/verify-node.py --qa '+Q+'/qa.json --subscription '+Q+'/subscription.json --binary '+ctx['client_binary']+' --port '+str(n['socks_port'])+' --expected-exit '+shlex.quote(n['expected_exit'])+' --expected-country '+n['country']+' --report '+Q+'/qa-report.json --run > '+Q+'/verify.stdout 2> '+Q+'/verify.stderr',300) data=read(gateway,Q+'/qa-report.json');report=json.loads(data) write(panel,B+'/qa-report.json',data) - # Reports contain endpoints and measured results, no passwords or tokens. - evidence={'key':key,'request_sha256':sha,'report':report,'panel':state(panel)} - (Path(a.state_dir)/(key+'-evidence.json')).write_text(json.dumps(evidence,ensure_ascii=False,indent=2)) for _ in range(24): s=state(panel) - if s['node_online'] and s['u']+s['d']>0: + if s['node_online'] and s['u']>0 and s['d']>0: + self.qa_evidence(n,data,s) r=receipt('ready');r['valid_until']=min(r['valid_until'],report['verified_at']+900);return r time.sleep(5) + self.qa_evidence(n,data,s) return receipt('waiting') if a.phase=='publish': s=state(panel) @@ -404,7 +413,7 @@ for attempt in range(3 if source['owned'] else 1): if not journal:return receipt('conflict') j=json.loads(journal) return receipt('ready' if j.get('manifest')==s['manifest'] and j.get('request',{}).get('mode')=='append' and j.get('request',{}).get('group')==n['formal_group_id'] and j.get('state')=='applied' else 'conflict') - if not s['node_online'] or s['u']+s['d']<=0:return receipt('waiting') + if not s['node_online'] or s['u']<=0 or s['d']<=0:return receipt('waiting') if a.action=='probe':return receipt('absent',True) cmd='cd '+APP+' && php '+OPS+'/promote.php --backup='+B+' --mode=append --group='+str(n['formal_group_id'])+' --verification='+B+'/qa-report.json' execute(panel,cmd);execute(panel,cmd+' --apply');return receipt('ready') diff --git a/tests/node-onboarding/AcceptanceUserIsolatedTest.php b/tests/node-onboarding/AcceptanceUserIsolatedTest.php index fa49600..5238867 100644 --- a/tests/node-onboarding/AcceptanceUserIsolatedTest.php +++ b/tests/node-onboarding/AcceptanceUserIsolatedTest.php @@ -35,7 +35,7 @@ final class AcceptanceUserIsolatedTest extends TestCase { Acceptance::cleanup($this->dir,true);self::assertTrue($u->fresh()->banned);self::assertTrue(App\Services\ServerService::getAvailableUsers(Server::first())->isEmpty());self::assertArrayNotHasKey('token',Acceptance::source($this->dir,1,true));$this->reject(fn()=>Acceptance::source($this->dir,1)); } public function testExistingEligibleAdminIsUsedWithoutPrivilegeOrPasswordChange():void { - $u=$this->user(['is_admin'=>1]);$before=$u->fresh()->toArray();$r=Acceptance::prepare($this->dir,1,true);self::assertFalse($r['owned']);self::assertSame($u->id,$r['id']);Acceptance::source($this->dir,1);Acceptance::cleanup($this->dir,true);self::assertSame($before,$u->fresh()->toArray()); + $u=$this->user(['is_admin'=>1]);$before=(array)Capsule::table('v2_user')->where('id',$u->id)->first();$r=Acceptance::prepare($this->dir,1,true);self::assertFalse($r['owned']);self::assertSame($u->id,$r['id']);Acceptance::source($this->dir,1);Acceptance::cleanup($this->dir,true);self::assertSame($before,(array)Capsule::table('v2_user')->where('id',$u->id)->first()); } public function testExpiredExhaustedBannedWrongGroupAreNotReused():void { foreach([['expired_at'=>time()-1],['u'=>1000000],['banned'=>1],['group_id'=>2]] as $i=>$change)$this->user($change+['email'=>'existing'.$i.'@example.invalid']); @@ -53,6 +53,11 @@ final class AcceptanceUserIsolatedTest extends TestCase { public function testCleanupRefusesDriftAndNeverBansSomeoneElsesAccount():void { $id=Acceptance::prepare($this->dir,1,true)['id'];$u=User::find($id);$u->is_admin=true;$u->saveQuietly();$this->reject(fn()=>Acceptance::cleanup($this->dir,true));self::assertFalse($u->fresh()->banned); } + public function testNoEffectivelyEligibleExistingUserCreatesBoundedTestAccount():void { + $old=$this->user(['is_admin'=>1]);$id=$old->id; + App\Services\Plugin\HookManager::registerFilter('server.users.get',fn($users)=>$users->where('id','!=',$id)->values()); + $r=Acceptance::prepare($this->dir,1,true);self::assertTrue($r['owned']);self::assertNotSame($id,$r['id']);self::assertFalse(User::find($r['id'])->is_admin);Acceptance::source($this->dir,1);Acceptance::cleanup($this->dir,true); + } public function testEffectivePluginExclusionBlocksAcceptanceSource():void { Acceptance::prepare($this->dir,1,true); App\Services\Plugin\HookManager::registerFilter('server.users.get',fn($users)=>collect()); diff --git a/tests/node-onboarding/test_runtime.py b/tests/node-onboarding/test_runtime.py index d64a00f..31ddc59 100644 --- a/tests/node-onboarding/test_runtime.py +++ b/tests/node-onboarding/test_runtime.py @@ -154,6 +154,63 @@ class ResumeTests(unittest.TestCase): class SiteAdapterTests(unittest.TestCase): + def test_qa_export_binds_raw_bytes_and_latest_traffic_for_independent_review(self): + from site_adapter import SiteAdapter + with tempfile.TemporaryDirectory() as d: + adapter=object.__new__(SiteAdapter);adapter.directory=Path(d);adapter.atomic_json=batch.atomic_json + raw=b'{ "state": "live_verified", "qa_user_id": 9 }' + latest=dict(node_online=True,u=12,d=34,manifest={'test_user_id':9}) + adapter.qa_evidence(dict(key='new',request_sha256='request'),raw,latest) + path=Path(d)/'new-evidence.json';value=json.loads(path.read_text()) + self.assertEqual(value['report_sha256'],hashlib.sha256(raw).hexdigest()) + self.assertEqual(value['panel'],latest);self.assertEqual(path.stat().st_mode & 0o077,0) + + def test_new_live_qa_exports_new_raw_report_after_transfer_not_prior_report(self): + import io + from site_adapter import SiteAdapter + with tempfile.TemporaryDirectory() as d: + adapter=object.__new__(SiteAdapter);adapter.directory=Path(d);adapter.atomic_json=batch.atomic_json + ctx=dict(panel_vps='panel',gateway_vps='gateway',node_root='/node',ops_root='/ops',app_root='/app',node_backup_root='/backups',qa_root='/qa',client_binary='/client',subscription_url='https://sub.example.invalid') + adapter.plan={'context':ctx};adapter.ctx=ctx + raw=json.dumps(dict(state='live_verified',qa_user_id=9,verified_at=int(time.time()),results=[dict(tag='new',live=True,strict_tls=True,exit_ip='192.0.2.11',country='JP')])).encode() + class Channel: + def shutdown_write(self):pass + def recv_exit_status(self):return 0 + class In: + channel=Channel() + def write(self,data):pass + class Out(io.BytesIO):channel=Channel() + class Writable(io.BytesIO): + def __init__(self,files,path):super().__init__();self.files,self.path=files,path + def close(self):self.files[self.path]=self.getvalue();super().close() + class Sftp: + def __init__(self,files):self.files=files + def __enter__(self):return self + def __exit__(self,*args):pass + def open(self,path,mode): + if mode=='w':return Writable(self.files,path) + if path not in self.files:raise FileNotFoundError(path) + return io.BytesIO(self.files[path]) + def chmod(self,*args):pass + def posix_rename(self,old,new):self.files[new]=self.files.pop(old) + state=dict(manifest={'test_user_id':9},node_online=True,groups=['2'],qa_banned=False,u=12,d=34) + class Remote: + def __init__(self,kind):self.kind=kind;self.files={} + def open_sftp(self):return Sftp(self.files) + def exec_command(self,cmd,timeout=0): + if cmd.endswith(' && php'):return In(),Out(json.dumps(state).encode()),Out() + if 'verify-node.py' in cmd:self.files['/qa/new/qa-report.json']=raw + return In(),Out(b'{}'),Out() + remotes={key:Remote(key) for key in ['panel','gateway','node']} + remotes['panel'].files.update({'/backups/new/node-config.yml':b'fixture-config','/backups/new/node-secret.env':b'fixture-env','/backups/new/qa-private.json':b'{}'}) + class Pool: + def get(self,key):return remotes[key] + adapter.pool=Pool() + node=dict(key='new',request_sha256='request',formal_group_id=1,vps_id='node',socks_port=19011,expected_exit='192.0.2.11',country='JP') + result=adapter.call(node,'verify','probe');self.assertEqual(result['status'],'ready') + exported=json.loads((Path(d)/'new-evidence.json').read_text());self.assertEqual(exported['report_sha256'],hashlib.sha256(raw).hexdigest());self.assertEqual(exported['panel']['d'],34) + self.assertEqual(remotes['panel'].files['/backups/new/qa-report.json'],raw) + def test_preserved_service_review_binds_original_and_exact_observation(self): from site_adapter import SiteAdapter adapter=object.__new__(SiteAdapter);adapter.ctx={'preserved_service':dict(unit='XrayR',binary='/bin/xrayr',config='/etc/xrayr.yml',port=443)} @@ -297,6 +354,9 @@ class PackagingTests(unittest.TestCase): self.assertEqual(git('rev-parse','HEAD').decode().strip(),source) git('bundle','verify',str(output)) with self.assertRaises(ValueError):module.build(repo,source,base,'codex/offline-ops',root/'second.bundle') + advanced=module.build(repo,source,result['commit'],'codex/offline-ops',root/'advanced.bundle',advance=True) + self.assertEqual(git('rev-parse',advanced['commit']+'^').decode().strip(),result['commit']) + with self.assertRaises(ValueError):module.build(repo,source,base,'codex/offline-ops',root/'stale.bundle',advance=True) def test_event_stream_preserves_final_result_and_avoids_status_polling(self): import subprocess spec=importlib.util.spec_from_file_location('event_fixture',Path(__file__).with_name('test_batch.py'));f=importlib.util.module_from_spec(spec);spec.loader.exec_module(f)