Skip to content

fix(config): reject config writes when remote validation fails#142

Open
Episkey-G wants to merge 3 commits into
masterfrom
fix/config-write-on-validation-failure
Open

fix(config): reject config writes when remote validation fails#142
Episkey-G wants to merge 3 commits into
masterfrom
fix/config-write-on-validation-failure

Conversation

@Episkey-G

@Episkey-G Episkey-G commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

问题

config add / config update 在远程校验失败时仍写盘,把一条明显报错的命令变成静默的配置损坏。均为既有缺陷,与 #141 (channel-key) 无关,git 历史可追溯至更早。

现象
🔴 D1 config add 校验失败仍创建 profile,且把用户传入的 --region cn-bj2 --zone cn-bj2-02 --project-id org-xxx 全部抹成空
🔴 D2 失败的 config add --active true 会把原有 active profile 踢下去,坏配置上位。用户看到报错以为无事发生,此后每条命令都用那个密钥为假、region 为空的 profile,且无任何线索指向病因
🔴 D3 config update 提前落盘 AK/SK,校验失败后 profile 停在「密钥已换、region/project 未换」的半更新状态
🔴 D4 config update 的 project 校验失败会清空 ProjectID 并保存
🟡 D5 校验的错误信息报告了另一个变量(timeout_sec 为 -5 时报 accept 0
🟢 D7 max-retry-timesc 拼写错误
🟢 D8 config update--public-key/--private-key 误标 Required

病根HandleError 只报告、不中断控制流,调用方报错后照常往下写盘。三个命令对「校验失败」有三种不一致的处理,不像有意设计,更像历史叠加:

命令 region 校验失败 project 校验失败
config(主命令) 保留原值后仍落盘 网络失败时采信用户输入并落盘
config add 清空并保存 清空并保存
config update return 清空并保存

方案

统一为 fail-closed:远程校验失败即整体 return,不写入任何字段。

这个口径本就是本仓库的原意 —— ucloud init 一直是 fail-closedconfigure.go:76-94),是 add/update 漂移了。另外,现状 fail-open 落盘的是 region='' project_id='' 的残缺 profile,本就不可用,所以「离线仍能配」是假象,fail-closed 不损失任何真实能力。

三处值得单独说明的决策:

1. errNoDefaultProject 必须放行。 账号「有项目但未设默认」属良性,init 认得并放行。但 getReasonableProject 此前用 %v 而非 %w 包装,切断了 errors.Is 链 —— 若 fail-closed 无脑 return,这类账号会从「能配」变成彻底无法配置。改用 %w 并在调用方 errors.Is 放行,口径与 init 一致。

2. config update 改在副本上校验。 GetAggConfigByProfile 返回的是 manager map 内条目本身,原地改写会让校验失败后的半成品被 OAuth token 刷新 handler 连带落盘。改为 draft := *cacheConfig,全部通过后才 UpdateAggConfig(&draft)

3. D3 的提前落盘直接删除,无需替代。 BuildClientRuntime 完全从传入的 AggConfig 构造 sdk.Config 与 credential,不读磁盘 —— AK/SK 写进内存即可供校验使用,那次 UpdateAggConfig 纯属多余。

测试

新增 6 个用例,覆盖三个命令的失败路径 + errNoDefaultProject 放行。每条都走完 FAIL→PASS 的复现闭环;主命令那条因写在修复之后,另经临时回退验证其确实会变红,排除 tautological test。

复用既有 fakeGatewayServer(httptest 假网关)并扩展为可按 action 指定响应 —— 这使「region 通过但 project 失败」这类组合可精确构造,D4 因此能进单测而非只靠代码审查。

⚠️ CI 的 test job 不含 ./cmd/...pr-gate.yml:228),本 PR 的 6 个用例 CI 一个都不会跑。审阅时请本地执行:

go test ./cmd/... -count=1

该 job 上方"cmd 测试打 live API"的注释是陈旧的(只有 uhost_test.go 如此且有 //go:build live 门禁)。此缺口不在本 PR 范围,另行处理。

D5/D7 用离线实测验证(两处检查都在 region 校验之前,不发网络请求):

timeout-sec       accept 0  →  accept -5
max-retry-times   accept 0  →  accept -1

max-retry-times 这条尤其隐蔽:%d*int 打印的是指针地址(nil 即 0)而非值,且 go vet 不报 —— %d 对指针在 Go 中是合法的,按整数格式化地址。

行为变更(用户可见,需入发版说明)

命令 场景 变更前 变更后
config add 校验失败 建 profile,字段被抹空 报错,不建
config add --active true 校验失败 坏 profile 上位 报错,active 不变
config update 校验失败 AK/SK 已落盘 报错,零写入
config update project 校验失败 ProjectID 被抹空 报错,不落盘
config(主命令) region 校验失败 保留原值,其余改动照落 报错,不落盘
config(主命令) project 网络失败 采信用户输入并落盘 报错,不落盘
三命令 账号无默认 project 报错但继续 放行,project_id 留空(与 init 一致)

成功路径零变化,go test ./... 全绿(与基线逐包一致),go vet 干净。

评审中追加发现(同批修复)

fail-closed 的前提是「校验失败 == 输入确实坏」。评审时专门反查了「合法输入被校验错判」的场景,修了两个:

D9 — config add 漏归一化 project-id(commit 2)。getProjectList 补全吐出的是 org-xxx/Name 形式,主命令与 update 都用 PickResourceID 剥成裸 id,唯独 add 没有。合法的补全值会被 getReasonableProject 判「project 不存在」——叠加 fail-closed 后,一个真实存在的 project 反被硬拦。补上归一化,与另两命令对齐。

D10 — config add 本地 timeout 检查排在远程校验之后(commit 3)。timeout_sec <= 0 是纯本地约束,却排在 getReasonableRegionZone 之后。坏 timeout 先被喂进校验请求(0 → http.Client 无超时、照打网关;负值 → 请求瞬败),用户看到 region 错、看不到真正的 timeout 错,还白打一次网络。移到远程校验之前,与主命令一致。

两者都用测试锁死(D9 归一化用例、D10 用毒网关断言「拒绝发生在任何远程调用之前」),均验证过回退修复即变红。

已知权衡:fail-closed 分不清「资源不存在」与「连接不上」

fail-closed(本 PR 选定)对「连接类配置让自己的校验请求失败」的子类有代价:--timeout-sec 1 过激、网络抖动、base-url 打不通等,会让合法配置也存不进去(尤其 config add 必须校验、无法跳过)。这是有意接受的权衡——相较之下 fail-open 存的是残缺配置,更糟。「只改元数据不该触发远程校验」的离线痛点由独立任务 config-conditional-validation 缓解(针对 update/主命令的改存量路径;add 新建仍需校验)。D10 是这一类中唯一能零成本前置消除的实例(纯本地约束),故并入本 PR。

不在本 PR 范围

  • D6--flag=value 静默失效):root.go:306 的 os.Args 手写扫描用精确比较,--base-url=X 不匹配故被无视。属用户可见行为变更(尤其 --profile=X 会从「静默失效」变为「真正生效」,可能让某些当前碰巧能跑的脚本切换 profile),需独立评估,已拆为子任务。
  • ucloud init 无条件重置用户自定义 base-url(configure.go:102):已有独立任务。
  • config 主命令未采用 draft 副本:属已知残留,落盘行为已由用例守住;单进程 CLI 下内存污染不可观测,若将来引入长驻进程需补。

config add/update 在远程校验失败时仍写盘,把一条「明显报错」的命令变成静默的
配置损坏:

- config add 校验失败仍创建 profile,并把用户传入的 region/zone/project 抹成空
- 失败的 config add --active true 会踢掉原有 active profile,坏配置上位,
  此后每条命令都用那个密钥为假、region 为空的 profile 且无线索指向病因
- config update 提前落盘 AK/SK,校验失败后 profile 停在半更新状态
- config update 的 project 校验失败会清空 ProjectID 并保存

病根是 HandleError 只报告、不中断控制流,调用方报错后照常往下写盘。三个命令
(config / config add / config update)对此有三种不一致的处理,不像有意设计。

统一为 fail-closed:远程校验失败即整体 return,不写入任何字段。该口径本就是
本仓库的原意 —— ucloud init 一直如此(configure.go:76-94),是 add/update 漂移了。

config update 改在 AggConfig 副本上校验,全部通过后才 UpdateAggConfig。
GetAggConfigByProfile 返回的是 manager map 内条目本身,原地改写会让校验失败后
的半成品被 OAuth token 刷新 handler 连带落盘。

errNoDefaultProject 保持放行:账号有项目但未设默认属良性缺失,一并拒绝会使该类
账号彻底无法配置。getReasonableProject 改用 %w 包装以恢复 errors.Is 链。

顺带修正:
- 错误信息报告被检查的那个值。timeout/max-retry 此前恒报 accept 0 —— 后者尤其
  隐蔽:%d 对 *int 打印的是指针地址(nil 即 0)而非值,且 go vet 不报(%d 对
  指针在 Go 中合法)
- max-retry-timesc 拼写
- config update 的 --public-key/--private-key 标注 Required → Optional(二者在
  update 中实为可选,仅 profile 是 MarkFlagRequired)

测试:新增 6 个用例覆盖三个命令的失败路径与 errNoDefaultProject 放行,均经
FAIL→PASS 复现闭环验证。注意 CI 的 test job 不含 ./cmd/...,须本地跑
go test ./cmd/... -count=1。

不含 D6(--flag=value 静默失效),已拆为子任务与 root.go 的 os.Args 扫描合并治理。
@github-actions

Copy link
Copy Markdown

🔴 平台 PR 默认硬拦,需管理员 Approve 放行(或由管理员提交)。判定:改动触及平台/受保护路径(cmd/configure.go, cmd/configure_test.go, cmd/region.go)

config add 漏了主命令与 config update 都有的 PickResourceID 归一化。
getProjectList 补全吐出的是 org-xxx/Name 形式(project.go:167),用户接受补全后
config add 收到的 --project-id 带斜杠,getReasonableProject 在裸 id 的 map 里查不到,
判为「project does not exist」。

叠加同批的 fail-closed 改动后果被放大:此前该错判会 HandleError 后继续、落盘一个
project 为空的 profile;fail-closed 后直接硬拦——一个真实存在的 project 反而让整条
config add 失败。属既有缺陷(归一化三命令不一致),但正落在本次收紧的路径上,故一并修。

新增 TestConfigAddNormalizesSlashProjectID:--project-id org-123/Default(真实存在)
必须归一化为 org-123 并建成 profile。已验证回退修复后该用例变红(非 tautological)。
config add 的 timeout_sec<=0 本地检查排在 getReasonableRegionZone 远程校验之后,
与主命令(最先检查 timeout)不一致。坏 timeout 会先被喂进校验请求:
--timeout-sec 0 → http.Client 变成无超时、照打网关;负值 → 请求瞬间失败。
两种情况下坏 timeout 都先污染了校验请求,用户看到的是 region 错、看不到真正的
timeout 错,还白打一次网络。叠加同批 fail-closed 后症状更糟——region 错直接
return,那个干净的「timeout must be > 0」永远执行不到。

把本地 timeout 检查移到远程校验之前,与主命令对齐:属「连接类配置让自己的校验
请求失败」这一类的一个实例(同类中 timeout 是纯本地约束,可零成本前置消除)。

新增 TestConfigAddChecksTimeoutBeforeValidation:--timeout-sec 0 + 毒网关(被调用
即失败),断言拒绝发生在任何远程调用之前。已验证修复前该用例因 GetRegion 被调用
而红。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant