feat(nodes): exclude small XrayR hosts and use registered credential fields

This commit is contained in:
hellcat 2026-10-20 16:24:46 +08:00
parent 4d667f37b2
commit d331f45b0e
9 changed files with 117 additions and 3 deletions

View File

@ -18,4 +18,7 @@
这项授权针对当前任务的子助手,不自动新建或分叉普通聊天,也不授权向已有聊天发消息。仅讨论方案、维护脚本/文档或单节点任务不自动触发。若子助手工具不可用、并发名额不足或助手失败,说明具体缺口并恢复/等待;未经用户明确允许,不静默省略角色或跳过验收。当前批次已有对应助手时复用,不重复启动。具体线上变更仍以用户本次接入授权为限;项目规则不启动无人值守接入。 这项授权针对当前任务的子助手,不自动新建或分叉普通聊天,也不授权向已有聊天发消息。仅讨论方案、维护脚本/文档或单节点任务不自动触发。若子助手工具不可用、并发名额不足或助手失败,说明具体缺口并恢复/等待;未经用户明确允许,不静默省略角色或跳过验收。当前批次已有对应助手时复用,不重复启动。具体线上变更仍以用户本次接入授权为限;项目规则不启动无人值守接入。
- 新增候选按[容量筛选](docs/maintenance/NODE_BATCH_RUNTIME.md#候选容量筛选):物理内存小于1GB且已安装XrayR则排除,不以停服务绕过,不追溯停用已接入节点。
- 接入所需邮箱验证码,按用户2026-10-20长期授权,先用已登记的对应邮箱自行登录并只读取本次验证邮件;凭据仍走GemDocs统一字段入口,不改账号安全设置、不读取无关邮件。只有资料缺失、认证失败或必须人工完成的验证才请求用户协助;账号切换及网站访问边界继续适用。
本文件提供维护入口,不扩大当前任务授权,也不启动无人值守自动维护。 本文件提供维护入口,不扩大当前任务授权,也不启动无人值守自动维护。

View File

@ -14,6 +14,12 @@
| `panel-acceptance.php` | 开工前选择有效正式组账号;缺少时创建本批受限非管理员测试用户,记录归属并收尾禁用 | | `panel-acceptance.php` | 开工前选择有效正式组账号;缺少时创建本批受限非管理员测试用户,记录归属并收尾禁用 |
| `acceptance.py` | 动态集合、逐节点结果、原始证据摘要、QA身份/禁用/拒绝/下发排除和时效的完整自动审计 | | `acceptance.py` | 动态集合、逐节点结果、原始证据摘要、QA身份/禁用/拒绝/下发排除和时效的完整自动审计 |
## 候选容量筛选
用户2026-10-20指定:物理总内存小于1GB且已安装XrayR的服务器,不作为DPL新节点。两条件须同时成立;已装但未运行、inactive或masked同样计算,不能临时停止/卸载旧服务来绕过。采用`/proc/meminfo`的MemTotal乘1024,1GB=1,000,000,000 bytes;不把1GiB误当门槛,不使用MemAvailable、空闲内存或swap。容量或安装证据不明时暂停核查,不按未安装放行。该规则筛选新增候选,不自动迁移、停用已接入节点。
预选时先核实;正式适配器每次preflight在准备正式测试账号和stage前再次执行`capacity_gate`,本地600回执绑定请求、字节数、安装状态和采集时间。小内存且已装时拒绝进入接入阶段;不能为了凑批次数忽略。满足本规则只代表未被该条件排除,仍须通过余下资源、原服务、端口与容量检查。
## 准备与Git部署 ## 准备与Git部署
先按CoDocs确认VPS、白色机访问路线、正式组、展示编号及资源冲突。Python执行环境使用`requirements.txt`的Paramiko 5.0.0;配置与执行必须使用同一解释器。安装来源限官方包源/已审核环境,不临时导入任务目录中的未知transport模块。 先按CoDocs确认VPS、白色机访问路线、正式组、展示编号及资源冲突。Python执行环境使用`requirements.txt`的Paramiko 5.0.0;配置与执行必须使用同一解释器。安装来源限官方包源/已审核环境,不临时导入任务目录中的未知transport模块。
@ -56,7 +62,7 @@ Certbot准备不能只检查`command -v`:还须实际运行`certbot --version`
批次备份目录必须独立且固定,不跨实际批次复用旧资源基线。新节点端口、正式客户端端口均查重预留;共享网关客户端还使用同一账本内的进程锁。当前适配器用于独立AnyTLS节点及明确列出的既有服务;不自动把白色机、面板或网关当节点宿主。 批次备份目录必须独立且固定,不跨实际批次复用旧资源基线。新节点端口、正式客户端端口均查重预留;共享网关客户端还使用同一账本内的进程锁。当前适配器用于独立AnyTLS节点及明确列出的既有服务;不自动把白色机、面板或网关当节点宿主。
`ssh.json`含`known_hosts`绝对路径及`hosts`映射,key为准确CoDocs VPS ID。每台登记`host`、`port`、`username`、用户已批准的`host_key_sha256`(OpenSSH SHA256格式)、可选`credential_ref`;白色机额外登记`role=white_jump`,ID须与`context.jump_vps`一致。候选IP必须与冻结计划相同。`credential_ref`只引用既定安全存储中的600文件。默认JSON支持password/key_filename/passphrase;既有CoDocs Markdown可设`credential_format=codocs-password`,仅按原文件中唯一的“密码:”字段在内存读取,不能为了适配而复制或改写原凭据正文。内容不得进入计划、日志、argv或Git。配置和出口登记摘要固定,凭据只按需读取,不默认自动轮换。 `ssh.json`含`known_hosts`绝对路径及`hosts`映射,key为准确CoDocs VPS ID。每台登记`host`、`port`、`username`、用户已批准的`host_key_sha256`(OpenSSH SHA256格式)、可选`credential_ref`;白色机额外登记`role=white_jump`,ID须与`context.jump_vps`一致。候选IP必须与冻结计划相同。`credential_ref`只引用既定安全存储中的600文件。新计划统一设`credential_format=gemdocs-resource`及准确`credential_resource=VPS-xxx`,通过GemDocs `tools/credentials.py`的`get_field`按登记格式读取password,原样交给SSH组件,不自行剥标签或trim。configure同时固定credentials.py/resources.py摘要;格式或字段异常停止,不回退全文。旧JSON/codocs-password分支仅保留旧冻结版本兼容,不用于新计划;原始凭据不改写、不复制。内容不得进入计划、日志、argv或Git。配置和出口登记摘要固定,凭据只按需读取,不默认自动轮换。
只从known-hosts文件选择与冻结配置中已批准指纹完全相符的公钥;找不到匹配时在读取/发送凭据前停止。全局known-hosts增加无关记录无需改变本批受信任指纹。不关闭主机密钥检查,不自动TOFU;首次信任先按访问规则核验/批准精确指纹,保存公开known-host条目。会话丢失只标记未知,不自动重发安装/开放命令;下次仍从现场probe恢复。 只从known-hosts文件选择与冻结配置中已批准指纹完全相符的公钥;找不到匹配时在读取/发送凭据前停止。全局known-hosts增加无关记录无需改变本批受信任指纹。不关闭主机密钥检查,不自动TOFU;首次信任先按访问规则核验/批准精确指纹,保存公开known-host条目。会话丢失只标记未知,不自动重发安装/开放命令;下次仍从现场probe恢复。

View File

@ -74,6 +74,7 @@ esac
| 节点 | OS/架构、内存/磁盘、时间同步、systemd、Git、curl、sha256sum、ss、证书工具,以及命令实际路径 | 先明确缺失工具和安装来源;安装属于接入任务变更,文档整理不安装 | | 节点 | OS/架构、内存/磁盘、时间同步、systemd、Git、curl、sha256sum、ss、证书工具,以及命令实际路径 | 先明确缺失工具和安装来源;安装属于接入任务变更,文档整理不安装 |
| 软件来源 | 发行版源可下载、包版本与依赖可满足;运维仓库和官方二进制来源/摘要可验证 | 不把 apt 索引存在当可安装;不自动换不明镜像或全量升级 | | 软件来源 | 发行版源可下载、包版本与依赖可满足;运维仓库和官方二进制来源/摘要可验证 | 不把 apt 索引存在当可安装;不自动换不明镜像或全量升级 |
| 证书 | 已有 renewal 配置、pre/post/deploy hooks及renew_hook别名、未知可执行hook、Certbot 是 apt 还是 Snap、可执行路径、timer/cron 的真实调用参数和运行用户 | 确定新证书隔离方案及可生效调度后再签发;不能仅添加 renewal 文件就视为自动续期完成 | | 证书 | 已有 renewal 配置、pre/post/deploy hooks及renew_hook别名、未知可执行hook、Certbot 是 apt 还是 Snap、可执行路径、timer/cron 的真实调用参数和运行用户 | 确定新证书隔离方案及可生效调度后再签发;不能仅添加 renewal 文件就视为自动续期完成 |
| 候选内存 | 按[容量筛选](NODE_BATCH_RUNTIME.md#候选容量筛选)核对物理MemTotal及XrayR是否已安装(不限active) | 小于1GB且已装XrayR则排除,未知先核查;不改动旧服务绕过 |
| 节点隔离 | 现有 XrayR 等服务的 PID/启动时间、TCP/UDP 监听、防火墙与新端口、旧证书和目录权限 | 冲突先处理;安装脚本只检查 TCP,不能代替全项前检 | | 节点隔离 | 现有 XrayR 等服务的 PID/启动时间、TCP/UDP 监听、防火墙与新端口、旧证书和目录权限 | 冲突先处理;安装脚本只检查 TCP,不能代替全项前检 |
| 中转 | 后台/登录账户按 CoDocs 读取,当前规则快照、目标落地、隧道及入口端口、Proxy Protocol 两端设置 | 缺入口先问,不改原规则;创建后回读实际值 | | 中转 | 后台/登录账户按 CoDocs 读取,当前规则快照、目标落地、隧道及入口端口、Proxy Protocol 两端设置 | 缺入口先问,不改原规则;创建后回读实际值 |
| 网关 | 容器 ID/启动时间/重启计数、CPU/内存、独立 QA 路径和端口、Python3、官方客户端及其摘要 | 补齐验收依赖后再 stage,不边创建业务记录边找工具 | | 网关 | 容器 ID/启动时间/重启计数、CPU/内存、独立 QA 路径和端口、Python3、官方客户端及其摘要 | 补齐验收依赖后再 stage,不边创建业务记录边找工具 |

View File

@ -2,6 +2,10 @@
文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。 文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。
## 2026-10-20 三节点新批次准备
新增候选门禁:MemTotal小于1,000,000,000 bytes且已安装XrayR则拒绝,在任何验收账号准备/stage前现场复查;未知不放行。新计划SSH凭据统一按GemDocs准确资源ID读取,值不trim,并固定读取模块摘要。50项Python离线回归通过。已批准sg02/03/04精确SSH指纹,三台实际总内存均8,294,055,936 bytes、XrayR已装、SG出口且24443空闲;候选筛选通过不等于接入完成。生产写入/最终验收待本批继续,本条不得作为已部署证明。
## 2026-10-20 验收按影响范围分层(本地实现) ## 2026-10-20 验收按影响范围分层(本地实现)
新节点隔离QA完整测试不减;正式开放后保持动态全量订阅/权限/QA清理与原资源基线检查,已评审独立append使用逐节点严格TLS连通检查;指定受影响节点完整联网,共享变化、未知影响或本任务新建正式验收账号全组完整联网。范围绑定计划及报告,旧结果不改。参数和边界唯一入口见[正式适配器](NODE_BATCH_RUNTIME.md#按影响范围验收2026-10-20)。本次46项Python离线回归通过(含新旧集合、范围绑定、分层探针分派、完整正式调用链和新建账号强制完整测试);未连接服务器、未部署新运维代码或进行线上验收。 新节点隔离QA完整测试不减;正式开放后保持动态全量订阅/权限/QA清理与原资源基线检查,已评审独立append使用逐节点严格TLS连通检查;指定受影响节点完整联网,共享变化、未知影响或本任务新建正式验收账号全组完整联网。范围绑定计划及报告,旧结果不改。参数和边界唯一入口见[正式适配器](NODE_BATCH_RUNTIME.md#按影响范围验收2026-10-20)。本次46项Python离线回归通过(含新旧集合、范围绑定、分层探针分派、完整正式调用链和新建账号强制完整测试);未连接服务器、未部署新运维代码或进行线上验收。

View File

@ -31,6 +31,13 @@ def snapshot(nodes):
return sorted(result, key=lambda n: n['id']) return sorted(result, key=lambda n: n['id'])
def candidate_eligibility(total_memory_bytes, xrayr_installed):
"""Use physical MemTotal, excluding swap and currently free memory. 1 GB = 10**9 bytes."""
if type(total_memory_bytes) is not int or total_memory_bytes <= 0 or type(xrayr_installed) is not bool:
raise ValueError('Unknown candidate memory or XrayR installation')
return not (total_memory_bytes < 1_000_000_000 and xrayr_installed)
def validate_impact(impact): def validate_impact(impact):
if impact is None: if impact is None:
return return

View File

@ -23,6 +23,8 @@ def configure(plan,site):
plan.pop('command_profile',None) plan.pop('command_profile',None)
plan['adapter']=[str(Path(sys.executable).resolve()),str(base/'command-adapter.py')] plan['adapter']=[str(Path(sys.executable).resolve()),str(base/'command-adapter.py')]
paths=[base/x for x in ('batch.py','site_adapter.py','ssh_pool.py','acceptance.py')]+[Path(site[k]) for k in ('ssh_config','exit_registry')] paths=[base/x for x in ('batch.py','site_adapter.py','ssh_pool.py','acceptance.py')]+[Path(site[k]) for k in ('ssh_config','exit_registry')]
if any(h.get('credential_format')=='gemdocs-resource' for h in adapter.pool.config['hosts'].values()):
paths += [Path('/Users/hellcat/Documents/GemDocs/AI版/tools')/name for name in ('credentials.py','resources.py')]
plan['runtime']={'kind':'ssh-site-v1','python':str(Path(sys.executable).resolve()),'paramiko_version':library.__version__,'files':{str(p):hashlib.sha256(p.read_bytes()).hexdigest() for p in paths}} plan['runtime']={'kind':'ssh-site-v1','python':str(Path(sys.executable).resolve()),'paramiko_version':library.__version__,'files':{str(p):hashlib.sha256(p.read_bytes()).hexdigest() for p in paths}}
return plan return plan

View File

@ -3,7 +3,7 @@ import base64,hashlib,json,os,shlex,threading,time
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from ssh_pool import Pool,private_json from ssh_pool import Pool,private_json
from acceptance import audit,digest,snapshot,service_change,provider_state,verification_scope,validate_impact from acceptance import audit,digest,snapshot,service_change,provider_state,verification_scope,validate_impact,candidate_eligibility
class SiteAdapter: class SiteAdapter:
def __init__(self,plan,directory,pool=None,atomic_writer=None,resource_lock=None): def __init__(self,plan,directory,pool=None,atomic_writer=None,resource_lock=None):
@ -69,6 +69,26 @@ class SiteAdapter:
for spec in source['nodes']: for spec in source['nodes']:
if spec['sni'] not in self.exit_registry:raise ValueError('Missing old-node exit expectation') if spec['sni'] not in self.exit_registry:raise ValueError('Missing old-node exit expectation')
def capacity_gate(self,node,execute):
script=r"""from pathlib import Path
import json,shutil,subprocess,re
text=Path('/proc/meminfo').read_text()
match=re.search(r'^MemTotal:\s+(\d+) kB$',text,re.M)
if not match:raise RuntimeError('memory_unknown')
units=[]
for unit in ['XrayR','xrayr']:
data=subprocess.check_output(['systemctl','show',unit,'-p','LoadState'],text=True).strip()
if data not in ['LoadState=loaded','LoadState=not-found','LoadState=masked','LoadState=bad-setting','LoadState=error','LoadState=merged']:raise RuntimeError('xrayr_installation_unknown')
if data!='LoadState=not-found':units.append(unit)
paths=[p for p in ['/usr/local/XrayR/XrayR','/usr/local/bin/XrayR','/usr/bin/XrayR','/etc/XrayR/config.yml'] if Path(p).exists()]
installed=bool(units or paths or shutil.which('XrayR') or shutil.which('xrayr'))
print(json.dumps({'total_memory_bytes':int(match.group(1))*1024,'xrayr_installed':installed}))
"""
observation=json.loads(execute(node,"python3 - <<'DPLCAPACITY'\n"+script+"\nDPLCAPACITY"))
if not candidate_eligibility(observation.get('total_memory_bytes'),observation.get('xrayr_installed')):
raise RuntimeError('candidate_low_memory_with_xrayr')
return observation
def certificate_gate(self,n,node,read,execute): def certificate_gate(self,n,node,read,execute):
owner=read(node,'/etc/dpl-node/batch-owner.json') owner=read(node,'/etc/dpl-node/batch-owner.json')
owned=bool(owner and json.loads(owner).get('request_sha256')==n['request_sha256']) owned=bool(owner and json.loads(owner).get('request_sha256')==n['request_sha256'])
@ -325,6 +345,8 @@ print(json.dumps({'status':code,'cache':headers.get('cf-cache-status'),'control'
return gateway_py(gateway,'Q='+repr(Q)+';SUB='+repr(ctx['subscription_url'])+'\n'+script) return gateway_py(gateway,'Q='+repr(Q)+';SUB='+repr(ctx['subscription_url'])+'\n'+script)
def phase_impl(panel,gateway,node): def phase_impl(panel,gateway,node):
if a.phase=='preflight': if a.phase=='preflight':
capacity=self.capacity_gate(node,execute)
self.atomic_json(self.directory/(key+'-capacity.json'),dict(capacity,request_sha256=sha,checked_at=int(time.time())))
with self.lock, self.resource_lock(self.directory.parent,ctx['panel_vps']+':panel'): with self.lock, self.resource_lock(self.directory.parent,ctx['panel_vps']+':panel'):
execute(panel,'install -d -m 700 '+ctx['batch_backup_root']) execute(panel,'install -d -m 700 '+ctx['batch_backup_root'])
execute(panel,'test "$(git -C '+OPS+' rev-parse HEAD)" = '+ctx['provision_commit']+' && test -z "$(git -C '+OPS+' status --porcelain)"') execute(panel,'test "$(git -C '+OPS+' rev-parse HEAD)" = '+ctx['provision_commit']+' && test -z "$(git -C '+OPS+' status --porcelain)"')

View File

@ -19,6 +19,15 @@ def private_json(path):
def credentials(spec): def credentials(spec):
if spec.get('credential_format') == 'gemdocs-resource':
import importlib.util,sys
root=Path('/Users/hellcat/Documents/GemDocs/AI版')
resource=spec.get('credential_resource','')
if not re.fullmatch(r'VPS-[0-9]{3}',resource):raise ValueError('Exact GemDocs VPS resource required')
if str(root/'tools') not in sys.path:sys.path.insert(0,str(root/'tools'))
module_spec=importlib.util.spec_from_file_location('gemdocs_credentials',root/'tools/credentials.py')
module=importlib.util.module_from_spec(module_spec);module_spec.loader.exec_module(module)
return {'password':module.get_field(root,resource,'password')}
if not spec.get('credential_ref'):return {} if not spec.get('credential_ref'):return {}
if spec.get('credential_format','json')=='json':return private_json(spec['credential_ref']) if spec.get('credential_format','json')=='json':return private_json(spec['credential_ref'])
if spec['credential_format']=='codocs-password': if spec['credential_format']=='codocs-password':

View File

@ -13,7 +13,7 @@ from unittest.mock import patch
SCRIPTS=Path(__file__).resolve().parents[2]/'scripts/node-onboarding' SCRIPTS=Path(__file__).resolve().parents[2]/'scripts/node-onboarding'
sys.path.insert(0,str(SCRIPTS)) sys.path.insert(0,str(SCRIPTS))
from acceptance import audit,digest,snapshot,service_change,provider_state,verification_scope from acceptance import audit,digest,snapshot,service_change,provider_state,verification_scope,candidate_eligibility
from ssh_pool import Pool,private_json,credentials from ssh_pool import Pool,private_json,credentials
import batch import batch
@ -146,6 +146,55 @@ class ProbeDispatchTests(unittest.TestCase):
with self.assertRaises(OSError):verifier.run_tls({'host':'relay.invalid','port':1234,'sni':'node.invalid'}) with self.assertRaises(OSError):verifier.run_tls({'host':'relay.invalid','port':1234,'sni':'node.invalid'})
class CapacityGateTests(unittest.TestCase):
def test_both_conditions_and_decimal_boundary(self):
self.assertFalse(candidate_eligibility(512*1024*1024,True))
self.assertFalse(candidate_eligibility(999_999_999,True))
self.assertTrue(candidate_eligibility(1_000_000_000,True))
self.assertTrue(candidate_eligibility(1010876*1024,True)) # Kernel-visible RAM on nominal 1GB host.
self.assertTrue(candidate_eligibility(512*1024*1024,False))
for amount,installed in [(None,True),(0,True),(True,True),(512000000,None),(512000000,'inactive')]:
with self.assertRaises(ValueError):candidate_eligibility(amount,installed)
def test_gate_rejects_low_memory_or_unknown_and_has_no_write_command(self):
from site_adapter import SiteAdapter
adapter=object.__new__(SiteAdapter);commands=[]
def execute(node,command):
commands.append(command)
program=command.split('\n',1)[1].rsplit('\nDPLCAPACITY',1)[0];compile(program,'capacity-remote','exec')
return json.dumps({'total_memory_bytes':512*1024*1024,'xrayr_installed':True})
with self.assertRaisesRegex(RuntimeError,'candidate_low_memory'):adapter.capacity_gate(None,execute)
self.assertEqual(len(commands),1);self.assertIn('/proc/meminfo',commands[0]);self.assertNotIn('systemctl stop',commands[0])
with self.assertRaises(ValueError):adapter.capacity_gate(None,lambda *args:'{}')
def test_generated_probe_handles_inactive_masked_missing_and_unknown(self):
import types,io,contextlib
from site_adapter import SiteAdapter
adapter=object.__new__(SiteAdapter)
def observation(memory, states, installed_path=False):
result=[]
class FakePath:
def __init__(self,value):self.value=value
def read_text(self):return memory
def exists(self):return installed_path and self.value=='/usr/local/XrayR/XrayR'
def execute(node,command):
program=command.split('\n',1)[1].rsplit('\nDPLCAPACITY',1)[0]
output=io.StringIO()
with patch.dict(sys.modules,{'pathlib':types.SimpleNamespace(Path=FakePath),'shutil':types.SimpleNamespace(which=lambda _:None),'subprocess':types.SimpleNamespace(check_output=lambda argv,**kw:'LoadState='+states[argv[2]]+'\n')}),contextlib.redirect_stdout(output):
exec(compile(program,'actual-capacity-probe','exec'),{})
result.append(json.loads(output.getvalue()));return output.getvalue()
adapter.capacity_gate(None,execute);return result[0]
absent={'XrayR':'not-found','xrayr':'not-found'}
self.assertFalse(observation('MemTotal: 500000 kB\n',absent)['xrayr_installed'])
for state in ['loaded','masked']:
with self.subTest(state=state),self.assertRaisesRegex(RuntimeError,'candidate_low_memory'):
observation('MemTotal: 500000 kB\n',dict(absent,XrayR=state))
with self.assertRaisesRegex(RuntimeError,'candidate_low_memory'):observation('MemTotal: 500000 kB\n',absent,True)
self.assertTrue(observation('MemTotal: 1010876 kB\n',dict(absent,XrayR='loaded'))['xrayr_installed'])
with self.assertRaisesRegex(RuntimeError,'memory_unknown'):observation('SwapTotal: 9000000 kB\n',absent)
with self.assertRaisesRegex(RuntimeError,'xrayr_installation_unknown'):observation('MemTotal: 9000000 kB\n',dict(absent,XrayR='unexpected'))
class TransportTests(unittest.TestCase): class TransportTests(unittest.TestCase):
def test_reuse_and_every_nonwhite_host_has_jump(self): def test_reuse_and_every_nonwhite_host_has_jump(self):
calls=[] calls=[]
@ -176,6 +225,17 @@ class TransportTests(unittest.TestCase):
for t in threads:t.start() for t in threads:t.start()
for t in threads:t.join(timeout=5) for t in threads:t.join(timeout=5)
self.assertEqual(len(made),2);self.assertIsNot(made[0],made[1]);pool.close() self.assertEqual(len(made),2);self.assertIsNot(made[0],made[1]);pool.close()
def test_gemdocs_reader_passes_exact_field_and_stops_on_error(self):
import types
secret=' fixture:with#spaces$ \n'
reader=types.SimpleNamespace(get_field=lambda root,rid,field:secret)
loader=types.SimpleNamespace(exec_module=lambda module:None)
with patch('importlib.util.spec_from_file_location',return_value=types.SimpleNamespace(loader=loader)),patch('importlib.util.module_from_spec',return_value=reader):
self.assertEqual(credentials({'credential_format':'gemdocs-resource','credential_resource':'VPS-030'}),{'password':secret})
reader.get_field=lambda *args:(_ for _ in ()).throw(ValueError('missing field'))
with self.assertRaises(ValueError):credentials({'credential_format':'gemdocs-resource','credential_resource':'VPS-030'})
with self.assertRaises(ValueError):credentials({'credential_format':'gemdocs-resource','credential_resource':'other-account'})
def test_existing_codocs_credentials_are_read_without_copy_or_rewrite(self): def test_existing_codocs_credentials_are_read_without_copy_or_rewrite(self):
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
p=Path(d)/'vps.md';body='# Fixture\n密码:test-only-phrase\n';p.write_text(body);p.chmod(0o600) p=Path(d)/'vps.md';body='# Fixture\n密码:test-only-phrase\n';p.write_text(body);p.chmod(0o600)