fix: finalize acceptance identity and raw QA review evidence
Some checks are pending
Docker Build and Publish / build (push) Waiting to run
Some checks are pending
Docker Build and Publish / build (push) Waiting to run
This commit is contained in:
parent
5c9157f5d7
commit
e26b5bc6ca
@ -18,13 +18,13 @@
|
|||||||
|
|
||||||
先按CoDocs确认VPS、白色机访问路线、正式组、展示编号及资源冲突。Python执行环境使用`requirements.txt`的Paramiko 5.0.0;配置与执行必须使用同一解释器。安装来源限官方包源/已审核环境,不临时导入任务目录中的未知transport模块。
|
先按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
|
```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
|
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钩子均停止。缺依赖须在准备阶段解决,不先创建一批面板对象后再临时安装工具。
|
适配器在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/启动时间/重启数再次核对。
|
每组正式客户端串行运行,保留完整订阅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。生产停机、失败回退、远端真实会话复用性能及十节点耗时尚未演练,不承诺固定提速倍数。
|
新增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
|
||||||
|
```
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
续跑保留全批前检及publish/audit现场关口,以请求/代码/当前依赖摘要和有效期复用已完成步骤,前驱变化向下游失效;事件输出替代重复状态轮询。原服务以unit/程序/配置/监听和进程信息区分实际冲突与待调查重启,原基线不覆盖。独立运维打包工具从已提交源码生成可追踪快照,保留工作区/真实索引及旧运维基础树;旧命令适配器及旧冻结任务文件不修改。
|
续跑保留全批前检及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 三节点批量接入完成
|
## 2026-10-17 三节点批量接入完成
|
||||||
|
|
||||||
|
|||||||
@ -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')
|
'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:
|
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')
|
raise ValueError('Explicit full commits and a new codex branch required')
|
||||||
destination=Path(bundle)
|
destination=Path(bundle)
|
||||||
@ -24,7 +24,11 @@ def build(repo,source,base,branch,bundle):
|
|||||||
return r.stdout
|
return r.stdout
|
||||||
git('cat-file','-e',source+'^{commit}');git('cat-file','-e',base+'^{commit}');git('check-ref-format','refs/heads/'+branch)
|
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
|
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:
|
with tempfile.TemporaryDirectory(prefix='dpl-ops-index-',dir=destination.parent) as temp:
|
||||||
env=dict(os.environ,GIT_INDEX_FILE=str(Path(temp)/'index'))
|
env=dict(os.environ,GIT_INDEX_FILE=str(Path(temp)/'index'))
|
||||||
git('read-tree',base,env=env)
|
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)
|
git('update-index','--add','--cacheinfo',mode+','+sha+','+name,env=env)
|
||||||
tree=git('write-tree',env=env).decode().strip()
|
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()
|
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)
|
git('bundle','create',str(destination),'refs/heads/'+branch)
|
||||||
destination.chmod(0o600)
|
destination.chmod(0o600)
|
||||||
return {'commit':commit,'source_commit':source,'branch':branch,'bundle_sha256':hashlib.sha256(destination.read_bytes()).hexdigest(),'files':len(FILES),'network':False}
|
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=argparse.ArgumentParser(description=__doc__)
|
||||||
p.add_argument('--repo',default=str(Path(__file__).resolve().parents[2]))
|
p.add_argument('--repo',default=str(Path(__file__).resolve().parents[2]))
|
||||||
for k in ('source','base','branch','bundle'):p.add_argument('--'+k,required=True)
|
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()
|
a=p.parse_args()
|
||||||
try:
|
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:
|
except Exception:
|
||||||
print(json.dumps({'state':'blocked','reason':'Inspect source/base, branch and bundle outcome before retry; no network performed'}));return 2
|
print(json.dumps({'state':'blocked','reason':'Inspect source/base, branch and bundle outcome before retry; no network performed'}));return 2
|
||||||
|
|
||||||
|
|||||||
@ -33,7 +33,13 @@ final class Acceptance {
|
|||||||
$j['id']=$u->id;$j['state']='active';if($apply)writeJson($path,$j);
|
$j['id']=$u->id;$j['state']='active';if($apply)writeJson($path,$j);
|
||||||
return ['state'=>'ready','id'=>$u->id,'owned'=>$j['owned']];
|
return ['state'=>'ready','id'=>$u->id,'owned'=>$j['owned']];
|
||||||
}
|
}
|
||||||
$u=User::where('group_id',$group)->where('banned',0)->whereRaw('u+d<transfer_enable')->where(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+d<transfer_enable')->where(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'];
|
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];
|
$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.
|
// Persist identity before commit. A lost response resumes the same row, never another user.
|
||||||
|
|||||||
@ -220,6 +220,15 @@ for attempt in range(3 if source['owned'] else 1):
|
|||||||
# Persist only nonsecret, byte-bound results for coordinator/operator review.
|
# 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})
|
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
|
@staticmethod
|
||||||
def php_value(value):
|
def php_value(value):
|
||||||
return "json_decode(base64_decode('"+base64.b64encode(json.dumps(value).encode()).decode()+"'),true)"
|
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')
|
existing=read(panel,B+'/qa-report.json')
|
||||||
if existing:
|
if existing:
|
||||||
report=json.loads(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
|
r=receipt('ready');r['valid_until']=min(r['valid_until'],report['verified_at']+900);return r
|
||||||
transfer(panel,gateway,node);fetch_qa(gateway)
|
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)
|
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)
|
data=read(gateway,Q+'/qa-report.json');report=json.loads(data)
|
||||||
write(panel,B+'/qa-report.json',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):
|
for _ in range(24):
|
||||||
s=state(panel)
|
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
|
r=receipt('ready');r['valid_until']=min(r['valid_until'],report['verified_at']+900);return r
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
|
self.qa_evidence(n,data,s)
|
||||||
return receipt('waiting')
|
return receipt('waiting')
|
||||||
if a.phase=='publish':
|
if a.phase=='publish':
|
||||||
s=state(panel)
|
s=state(panel)
|
||||||
@ -404,7 +413,7 @@ for attempt in range(3 if source['owned'] else 1):
|
|||||||
if not journal:return receipt('conflict')
|
if not journal:return receipt('conflict')
|
||||||
j=json.loads(journal)
|
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')
|
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)
|
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'
|
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')
|
execute(panel,cmd);execute(panel,cmd+' --apply');return receipt('ready')
|
||||||
|
|||||||
@ -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));
|
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 {
|
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 {
|
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']);
|
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 {
|
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);
|
$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 {
|
public function testEffectivePluginExclusionBlocksAcceptanceSource():void {
|
||||||
Acceptance::prepare($this->dir,1,true);
|
Acceptance::prepare($this->dir,1,true);
|
||||||
App\Services\Plugin\HookManager::registerFilter('server.users.get',fn($users)=>collect());
|
App\Services\Plugin\HookManager::registerFilter('server.users.get',fn($users)=>collect());
|
||||||
|
|||||||
@ -154,6 +154,63 @@ class ResumeTests(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class SiteAdapterTests(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):
|
def test_preserved_service_review_binds_original_and_exact_observation(self):
|
||||||
from site_adapter import SiteAdapter
|
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)}
|
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)
|
self.assertEqual(git('rev-parse','HEAD').decode().strip(),source)
|
||||||
git('bundle','verify',str(output))
|
git('bundle','verify',str(output))
|
||||||
with self.assertRaises(ValueError):module.build(repo,source,base,'codex/offline-ops',root/'second.bundle')
|
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):
|
def test_event_stream_preserves_final_result_and_avoids_status_polling(self):
|
||||||
import subprocess
|
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)
|
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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user