The deferred-seams entry that waited for a real-Postgres harness is struck: ClaimServer owns the gate now, proven red-then-green by the pgint concurrency test (two claims, one win, one 403), and the storage-cache zeroing found in the same pass is recorded with its hermetic test. auditfix19 is live on the VM.
23 KiB
23 KiB
Felis 生产就绪审计 — 2026-09-22(真机 E2E + 混沌)
分支:全部修复逐 commit 直接推 main(不再用功能分支)。环境:CentOS Stream 9 / aarch64 / k3s v1.36.4,
IPv6-only 接入(ssh -6 -i ~/.ssh/id_ed25519 root@fdb2:2c26:f4e4:0:21c:42ff:fede:69ec),
面板经 socat TCP6:443 → 127.0.0.1:30443 中继(手动启动,重启 VM 后需重开)。
已修复并验证(分支内)
| # | 缺陷 | 证据 | 修复 |
|---|---|---|---|
| 1 | 失败恢复后重试被静默吞掉:restore Job 固定名 + ErrAlreadyExists 一律当"幂等成功";失败 Job 占名 10 分钟(TTL),期间重试返回 202 restoring 但什么都不跑 |
真机:坏 ref 制造失败 → 立刻合法重试 → Job 原地不动、无新 Pod | k8sjobs.go:撞名时检查已完成(成功/失败)→ 删除+等 finalizer 释放(有界 10s)+ 重建;进行中仍幂等吸收。单测 3 个。真机复验:重试 4s 完成恢复 |
| 2 | 备份 Job 全部 FailedMount:Job 在 minecraft 命名空间挂 felis-config,而 bootstrap 只在 felis 命名空间创建该 Secret |
事件:MountVolume.SetUp failed: secret "felis-config" not found |
felis setup 用既有 ensureSecretReplica 把 felis-config(felis.toml) 复制到 minecraft;VM 上手工复制后备份成功(167MB 归档) |
| 3 | RBAC 缺 jobs:get/delete(修复 #1 需要) |
Role 检查 | APIMinecraftRole jobs → create/get/delete,测试同步更新 |
| 4 | gofmt 9 文件漂移 + CI 无 gofmt 门禁;staticcheck 9 处 | 基线扫描 | 全部修复;CI 加 gofmt job |
| 5 | 依赖漏洞:pgx v5.7.1(GO-2026-5004,可达 pgrepo.go)、x/net、x/text | govulncheck | 升 pgx v5.9.2 / x/net v0.55.0 / x/text v0.39.0 |
| 16 | ConsumeLoginEmailOTP PG 实现与接口契约漂移:契约/fakeRepo 说「同 VerifyEmailOTP 生命周期(扣尝试/锁定/ErrOTPInvalid/ErrOTPLocked)」,PG 却是单条 UPDATE+ErrNotFound → 错码/重放/过期在 login 门、op-login finish、migration confirm 三个入口全部 500;且尝试次数永不累计、otpMaxAttempts 锁定失效 |
真机:login 门重放正确码 → HTTP 500;修复前错码不扣次。修复后复测:5 次错码 400 且 attempts=5(正确码因锁定也 400、码未消费)、新码可用、重放 400 | pgrepo.go 改置为 VerifyEmailOTP 同构事务(FOR UPDATE、先锁后比、mismatch 扣次、match 消费),无 users 写副作用。提交 52549f7 |
| 17 | ListPendingOpLogins PG 少列:接口注释承诺「joined to its staff username」,handler 输出 username/created_at,fake 正确填充;PG SQL 未 JOIN 也未取 created_at → 真机 pending 列表 username 为空、created_at 为 0001-01-01 |
真机 internal /op-login/pending 响应 |
pgrepo.go SQL 改为 JOIN users + 取 created_at。提交见分支 |
| 18 | 绑定码并发兑换 500:RedeemPlayerBindCode 无 FOR UPDATE(同文件 VerifyLinkCode 有),且裸 INSERT。6 路并发同码兑换 → 3×HTTP 500(users_username_key 唯一冲突)+ 1×400 + 2×200;跨码并发同 UUID 同样会撞 |
真机四组并发测试 + API 日志 unmapped error ... duplicate key |
同码:码行 FOR UPDATE(输家干净地 400 invalid_code);跨码:INSERT users ... ON CONFLICT (username) DO NOTHING+重读、account_links ON CONFLICT (mc_uuid) DO NOTHING(两路汇聚同一 user)。复测:同码×6=1×200+5×400、双码×2=2×200 同 ID、DB 干净、日志零 unmapped |
| 19 | reaper CronJob 渲染位置错误,永远无法调度:felis manifests 把 CronJob 渲染到控制 ns,却引用 minecraft ns 的备份 PVC(Pod 不能跨 ns 挂 PVC:真机 FailedScheduling: persistentvolumeclaim "felis-backups" not found);修正 ns 后又发现 ServerAccount 也不能跨 ns 使用(serviceaccount "felis-reaper" not found)。而 reaper 的 Role/RoleBinding 本就在 minecraft ns |
真机三层取证(PVC/SA/调度) | CronJob 与 SA、RoleBinding subject 全部移到 MinecraftNamespace(提交 e4f2cff+c839454)。修复后完整演练通过(见下) |
| 6 | 默认安装无备份能力 + 回收链路不可达/不可用:(a) 没有任何环节渲染归档 PVC,FELIS_BACKUP_PVC 永远为空 → backup/restore 恒 503;(b) bootstrap 从不下传 reaper 三旗标 → 官方安装路径根本无法启用回收;(c) 即便启用,stock k3s 的 <root>/<pvc> 布局不存在,且 k3s storage root 是 0700 root:root、reaper pod 以 uid 1000 运行 → 真机 lstat /worlds/…: permission denied(fail-closed 跳过,但纯空转);(d) README 口径(“定时备份”)不实 |
真机全链路:默认渲染 PVC+env → 建服→marker→备份 202→jobs 端点 running→succeeded→改 marker→restore→读回原值 ✅;再建 resolvecheck 世界(20d idle,marker)→ CronJob 手动 Job:evaluated=2 reaped=1,归档含 marker、PVC+宿主目录回收、world_backups 得 inactive_15d 行、servers 行/CR 保留 ✅ |
platform/workloads.go 渲染归档 PVC(minecraft ns、RWO 10Gi、默认 SC),felis manifests --backup-pvc 默认 felis-backups(= 空为显式关闭),bootstrap 统一下传 env/旗标并写 [archive] local_path;resolveWorldDir 新增精确 local-path 目录解析(读 PVC spec.volumeName,非 glob,杜绝陈旧 PV 目录顶替);ReaperRole 增 pvc:get;bootstrap 设 FELIS_WORLDS_HOST_PATH 时给 uid 1000 授 traverse(setfacl/o+x);README/故障手册改写。提交 fd33fd0+2b87a5a |
| 8 | 磁盘打满灾难链:DiskPressure → kubelet 驱逐控制面(无 PriorityClass 保护)→ 镜像被 GC(无外网、registry 空)→ 全部 ImagePullBackOff;释放后数分钟才恢复调度。恢复依赖人工 `docker save | k3s ctr images import -` | 三轮真机 drill(原缺陷复现 + 两轮修复验证):①自定义 1e6 类:游戏 pod 先走、控制面随后仍被驱逐(kubelet 日志逐条列出 ranked/evicted),随后镜像被 GC → ErrImagePull;②内建 system-cluster-critical(2e9):填盘至 1.7G free,kubelet 对 felis-api/operator/registry 全部报 “cannot evict a critical pod”,三者在整个 DiskPressure 期间保持 Running;游戏 pod(0)被驱逐;③释放磁盘后:DiskPressure 经 ~5 分钟(eviction-pressure-transition-period)转 False——即“恢复慢”的主因;被 GC 的游戏镜像按 runbook 重导入后 25s 恢复 ✅ |
| 9 | 升级策略 Recreate:单副本 + Recreate:任何控制面升级=停机;坏升级(错 tag)中断约 95s 且需人工 rollout undo(无自动回滚) |
复核部署模板(Recreate + 单副本 + 无 leader election 的注释理由成立) | 不改策略(Recreate 是对无 leader election 的正确取舍),改为固化 runbook:troubleshooting §15 = 升级即重跑安装器;停机窗口=rollout 时长;坏镜像在安装器 180s 等待内以 kubectl describe 诊断呈现;回滚 kubectl rollout undo(镜像被 GC 时先按 §13b 重导入)。提交见下 |
| 10 | 备份语义:归档包含整个 /data(jar、libraries、cache),167MB;是否符合 world backup 定位待评估 | 真机 tar 清单(cache/、config/、eula.txt、server.properties、jar)+ 恢复语义(overlay+prune) | 评估结论:整卷备份是正确语义(回滚=整服状态回滚,含配置/插件),保留行为;文档化:troubleshooting §10「backup contains」+ OpenAPI/README 口径改为“整服数据卷”。提交见下 |
| 20 | 验证邮箱唯一性只存在于注释里:errors.go/repo.go 都声称迁移 0010 建了 users_verified_email_unique 部分唯一索引、VerifyEmailOTP 会返回 ErrEmailTaken;实际上索引从未创建、ErrEmailTaken 全仓库从未被返回 → 两个账号可同时验证一个邮箱,而登录门正是按 verified email 解析账号 → 同一邮箱的登录码归谁由数据库任意决定 |
pgint 首跑即红:第二账号验证同邮箱返回 nil;grep users_verified_email_unique internal/store/migrations/*.sql 零命中。真机复验(auditfix17 邮箱 409 演练):同码二次 verify 仍 409(码未被消费) |
新增迁移 0020_verified_email_unique.sql(lower(email) WHERE email_verified);VerifyEmailOTP 在消费码前查「他人已验证」→ ErrEmailTaken(不消费码、不扣次数),并把并发唯一冲突映射为同一答案;handler 新增 409 email_taken。提交 b6ef27c;PG 契约测试基建 2a55a0d(-tags pgint,见 CONTRIBUTING) |
| 21 | admin 改邮箱不清 verified:UpdateUser 写入新地址但保留 email_verified=true → 面板改错一个字符就能让登录码寄到别人邮箱(该 flag 正是邮箱登录解析/寄码的依据);fake 同错 |
pgint 契约测试 | 改地址时同写 email_verified = email_verified AND email IS NOT DISTINCT FROM 新值(同值 no-op 保留证明;换值即清除);fake 同步。提交 d1ec40f。真机验证:PATCH→f、PATCH 回原值仍 f、玩家重验证→t |
| 22 | role='owner' 是死信:迁移 0011 加了 owner 角色并把全部用户管理路由压在 IsOwner() 上,但没有任何代码写过 owner——breakGlass(UpsertOwner)、setup MC 绑定(CompleteOwnerSetup)、重置路径一律写 admin → 全新安装的整个 owner 层(用户列表/创建/编辑/禁用/删除/配额/会话)不可达;且新角色没进各处 staff 判定(op-login 只认 admin、玩家邮箱门只拒 admin、reclaim 保护与 AdminExists 只认 admin);面板一旦有 owner 行还可被降级/删除/禁用 |
真机:owner 会话加载 /api/v1/users 403;promote 后 200。op-login:修复前 owner start 中立不发码,修复后铸请求+finish 得 role=owner 会话 |
UpsertOwner/CompleteOwnerSetup 改写 role='owner'(冲突臂重断言,即 0011 文档的 promote 路径);op-login 双端改 staffRole;玩家邮箱门改 role != 'user';IsProtectedAdminLink/AdminExists 计入 owner;面板新增守卫:owner 行不可降级/删除/禁用(用户名/邮箱编辑仍可)。提交 e0d2378 |
| 23 | 配额门非原子 + storage 缓存被清零:(a) audit #4:QuotaCheck 与 ClaimServer 两条语句,同一用户并发认领两台无主服可双双通过 max_servers(deferred-seams 曾把这条挂为“只能在真 PG 上关闭”);(b) 更隐蔽:PATCH 资源时 UpdateServerResources(..., 0) 把本不能改的 storage 缓存写 0,而缓存列是配额聚合的唯一输入 → 此后该服的 storage 维度在配额里凭空消失 |
(a) 新增 pgint 并发测试:修复前两台全赢;修复后恰 1 赢 + 1 ErrQuotaExceeded,DB 只 1 行 owned;(b) hermetic 测试 TestPatchServerPreservesStorageCache(修复前 resourceUpdates 里 storage=0) |
(a) 门槛进 ClaimServer:同一事务内 pg_advisory_xact_lock(hashtext(user_id)) + 四维重查(与 QuotaCheck 共用 quotaAllows 防漂移)+ 行 FOR UPDATE,两个 claim handler 把 ErrQuotaExceeded 映射为与串行一致的 403;(b) resize 前读取现值并透传 storage。提交 bb68fef;deferred-seams 对应条目核销 |
待决策台账(未修)
前两日台账的 #7、#11–#15 已全部修复(本批核销,证据见下),不再挂在"未修"里:
| # | 主题 | 状态 |
|---|---|---|
| 7 | 异步失败不可感知 | ✅ 修复:GET /api/v1/servers/{name}/jobs(ff7c57c,含 RBAC jobs:list 与 OpenAPI);真机 drill 用它看到 running→succeeded |
| 11 | PG 断连报 401 | ✅ 修复:会话存储故障改为 503(2a8f897) |
| 12 | ready 门滞后 | ✅ 修复:operator 启动期内每 2s 重探 RCON(a2df2f2) |
| 13 | setup URL 截断 | ✅ 修复:TUI 折行(abce381) |
| 14 | controller-runtime 日志噪音 | ✅ 修复:SetLogger 接 slog(a415246) |
| 15 | reaper 启用未演练 | ✅ 已演练(见"第二日"节;CronJob 仍 suspend=true 防误删) |
已验证事实(正向清单)
- 安装→hook 发码→Owner 绑定→passkey(虚拟认证器)→面板管理员全链路 ✅
- 建服(POST /servers)→ 唤醒(operator 拉 StatefulSet pod)→ RCON
list→ SSE 控制台 → 停止 ✅ - 文件编辑:列目录/读/写(wire 为 base64)/256KiB 413/路径穿越 5 变体全拦截/运行中 409 ✅
- 备份→篡改→恢复数据演练:v1→备份→v2→恢复→读回 v1 ✅(G2 数据可恢复)
- 毒档案 fail-closed:穿越/绝对路径/符号链接条目 →
archive entry escapes target退出码 1,零写入 ✅ - 混沌:PG 掉线(healthz 仍 200、恢复后连接池自愈)、API pod 击杀(~2s 中断)、整机重启(32s 回归、会话/CRD/停止态全保留)✅
felis update报告(k3s/velocity 有更新、私有仓库 404 优雅处理)✅- 账户全套真机 E2E:绑定码新玩家注册(幂等/并发见 #18)、邮箱验证(onboarding 门)、邮箱 OTP 登录(错码扣次/5 次锁定后正确码也 400、重放 400、staff 账号 403 拒绝)、Passkey 注册+discoverable 登录+邮箱优先登录(虚拟认证器;Chrome 要求
Page.bringToFront才能过 focus 检查)、登出吊销会话(旧 cookie 401) - op-login 全状态机(start→status→approve→finish;早 finish 不烧码、错码扣次且请求保留、重放/重复批准/非管理员批准/未知 handle 全部按契约返回)✅
- 并发:OTP 风暴 8 路 = 1×202 + 7×429 且仅铸 1 码;绑定码并发(同码/双码)见 #18 ✅
- reaper 全链路真机演练(修复 #19 后):绑定挂载假世界 →
felis reaper一 pass:归档 tar 落盘(内容含标记文件)、world_backups行inactive_15d+90d 过期、PVC 删除且宿主目录回收、servers 行保留且 activity 时钟重置(红线②)、CRD 保留;第二 pass:伪造过期归档被驱逐(expired=1,文件删、行转deleted),合法归档未动;evaluated=2 reaped=1只动到期的世界 ✅
本轮新增真机证据(第二日)
- 默认安装的备份闭环:apply 新 bundle → 归档 PVC 自动创建(WaitForFirstConsumer,被首个 backup Job 拉绑定)→
POST /backup202 →GET /jobsrunning→succeeded → 漂移 marker →restore-backup→ 读回备份前内容 ✅ - 回收闭环(真实 k3s 布局 + 权限):resolvecheck(20d idle)→ CronJob 派 Job:
evaluated=2 reaped=1;归档含 marker、PVC+宿主目录回收、world_backups记inactive_15d;期间先经历lstat /worlds/…: permission denied(k3s storage root 0700)→ 修 ACL/root 权限后通过 ✅ - 磁盘压力三连 drill:①1e6 自定义类:游戏 pod 先走、控制面随后仍被逐(kubelet ranked/evicted 日志)→ 镜像 GC → ErrImagePull;②改内建
system-cluster-critical后:cannot evict a critical pod× felis-api/operator/registry,全部保持 Running;③恢复阶段实测DiskPressure条件 ~5min(eviction-pressure-transition-period)转 False,被 GC 的游戏镜像按 runbook 重导入后 25s 恢复 ✅ - 混沌:SIGKILL k3s 主进程 → kubectl 与 felis-api ready 均在 10s 内恢复(api pod 本身未被重启)✅;内存压力(2.6G tmpfs 满写):无 OOM kill、无人被逐(swap 5.9G 吸收 + 内核回收),控制面不受影响 ✅
- 构建链路定位(当时未修):Kaniko/Trivy 默认镜像是外部的 → 已加
[registry] kaniko_image/trivy_image/build_cpu_limit/build_mem_limit覆写;上下文跨 ns 不可挂载(PVC 不能跨 ns)是设计级缺口,s3 lane 也缺凭据注入 ✅(证据:internal/submit/blobstore.go:40、cmd/felis/api.gocontextBase 分支、jobspec 无 volumes/env)→ 第三批已修复,见下
本轮新增真机证据(第三批:探针 + 构建链路全通)
- 控制面探针(
0c8e29b):felis-api / felis-operator / registry 三个 Deployment 此前完全没有探针。修复后真机验证:api、operator/readyz+/healthz(internal 8081),registry/v2/(含命名端口解析);三个 PodReady=true、restartCount=0、无Unhealthy事件;operator 新增--health-probe-bind-address(8081,与 metrics 8080 分离,零检查也 404 → 已注册 ping) - 构建链路全通(
f79e5eb+02fd2de):- 传输:context_ref 改为 internal-face URL;
felis fetch-contextinitContainer 走 service token(对象来自felis-build里按bootstrap/felis setup同款 Secret 复制机制落地的felis-service-token;netpol 只放行控制 ns:8081)+ zip-slip 安全解包到限容 emptyDir;Kaniko--context=/context只读挂载 - 真机 E2E:玩家 OTP 登录 → 建 submission → 上传 gzip context(marker
felis-e2e-build-marker)→ Owner approve → Job:fetch ✅ → Kaniko 构建并 pushregistry.felis.svc:5000/user-uploads/<sub>:latest✅ → Trivy 扫描(内建镜像 DB)✅ → JobComplete;从 registry 拉回镜像校验/hello.txt内容 = 上传的 marker ✅ - 演练中真机抓到并修复 3 个单测看不到的缺陷:① Job requests=limits(2C/4Gi)在 4C/5.5G starter 上永远 Pending(
FailedScheduling/Insufficient memory)→ requests 改为地板值(250m/512Mi,不超上限);② Kaniko 重拷 Dockerfile 时 chown/chmod 到源文件 owner(distroless uid 65532)在 drop-ALL 能力下失败(copying dockerfile: chown … operation not permitted)→ fetch 容器以 root 解包(= Kaniko 自身 uid);③ Trivy DB 默认mirror.gcr.io正被 egress 锁拒绝(fail-closed)→ 新配置[registry] trivy_db_repository+ 文档 §8e 固化镜像配方(docker pull/tag/push aquasec/trivy-db:2进内建 registry;--insecure已覆盖明文 HTTP)
- 传输:context_ref 改为 internal-face URL;
本轮新增真机证据(第四批:PG 契约测试 + 验证邮箱唯一性 + owner 角色)
- PG 契约测试首跑抓到 #20:
internal/pgint对着真实 PG(felis_pgint,drop schema + 重放迁移)跑会话/OTP/op-login/绑定码/submission/build 全契约;TestOnboardingEmailOTPRejectsTakenEmail当场变红 → 挖出「索引从未创建 +ErrEmailTaken从未返回」。 - 迁移 0020 真机应用:
felis migrate up→applied 1 migration(s): [20];schema_migrationsmax=20;pg_indexes出现users_verified_email_unique。 - 邮箱唯一性真机 409 演练(auditfix17):owner 已验地址被玩家 onboarding 流程二次验证 → 第一次 409
email_taken;同码重放仍是 409(证明码未被消费,符合契约;若被消费会是 400)。 - admin 改邮箱清 verified 真机演练(auditfix18):PATCH player.test →
[email protected]后email_verified=f;PATCH 回原地址仍f;玩家走 onboarding 重验证 →t。 - owner 角色真机闭环(auditfix18):owner 行按 0011 文档升级路径置
role='owner'→GET /api/v1/users200(修复前 403);owner op-login start 铸请求+发码、finish 得role=owner会话;PATCH owner role / DELETE owner / DISABLE owner 全部 403;玩家邮箱登录回归 200。 - 配额原子门(第五批,
bb68fef,auditfix19 已上线):pgint 并发实证(真实 PG 上两路并发认领:恰 1 赢 + 1ErrQuotaExceeded;修复前两路全赢);docs/deferred-seams.md的 audit #4 条目核销。
结论:离"生产可用"还差什么(按优先级)
构建链路的上下文通道✅ 已修(f79e5eb/02fd2de,真机全链路含拉回校验;Trivy DB 需按 §8e 镜像一次)。- 镜像耐久:kubelet 仍可能 GC 掉"当前无人使用"的镜像(游戏镜像已实测发生)。把控制面与游戏镜像推入内建 registry 并让 Deployment/StatefulSet 引用 registry 路径,可免去"事故后人工重导入"。本轮已用 runbook(troubleshooting §13b)兜住。
PG 级契约测试✅ 已落地(2a55a0d):internal/pgint(-tags pgint,需FELIS_TEST_PG_URL指向名字含pgint的库,harness 会 drop schema + 重放真实迁移)已覆盖会话/OTP/op-login/绑定码/submission/build/owner 角色;首跑即抓到 #20(索引与 ErrEmailTaken 从未存在)。运行方式见 CONTRIBUTING.md。- 面板把 /jobs 显示出来:API 已有出口(#7),
ServerBackups页加一块"最近操作"即可让普通用户看见失败原因。 - 多节点回收:reaper CronJob 无 nodeSelector,多节点需按卷所在节点加 selector(代码与文档均已标注,未实现调度约束)。
- 告警:felis_* 指标已全有生产者,但无非告警;磁盘/内存阈值告警建议随部署一并给出。
- 玩家可见的构建结果(新发现):构建失败目前只有 admin 路由(
/images/build/{id})能看到;/me/submissions只回approved,玩家看不到 failed/succeeded。建议在 submission 视图附上build_status(或在面板里链到构建详情)。
系统性观察
- PGRepo 与接口契约/fake 漂移(#16/#17/#18):
fakeRepo与接口注释是对的、PG 实现在细节上落后,单测全绿也发现不了。建议后续引入 PG 级契约测试(testcontainers 或针对关键写路径的集成测试),重点覆盖「接口注释承诺了字段/错误码/生命周期」的方法。 - 部署知识:交叉编译必须先把
panel/dist拷进internal/panel/static再go build,否则镜像内 SPA 缺失(页面显示 “assets were not built”)。本批镜像为felis:auditfix7。
复现入口速查
- 面板会话 cookie:
/tmp/felis-cookies.json;API 助手:/tmp/fcurl.sh - 测试服:
test-one(minecraft ns,stopped);合法备份bk-47ee2e7e96e5a4ca9d0e51b805518bac - CDP 调试口:Mac
127.0.0.1:9333(独立 Chrome,profile/tmp/felis-chrome2);WebAuthn 虚拟认证器需在同一 CDP 会话内完成仪式,且先Page.bringToFront(否则 NotAllowedError: page does not have focus) - 已部署到 VM:
felis-api/felis-operator镜像 =felis:auditfix18(含 #20–#22 全部修复;迁移 0020 已应用,schema_migrationsmax=20) - 构建链路 drill 现成条件:
felis-build里有felis-service-token(Secret 复制);felis-config里kaniko_image/trivy_image指向 k3s containerd 已导入的 pin tag、trivy_db_repository = registry.felis.svc:5000/mirror/trivy-db:2(镜像配方 §8e;VM 上 docker daemon 的insecure-registries已含 registry ClusterIP) - VM 内部面:
k3s kubectl -n felis port-forward svc/felis-api-internal 18081:8081(Pod 重建后转发会悬死,需重启);reaper CronJob(minecraft ns)已应用但suspend=true