Cloud 平台适配:Consul 发现/配置与 Vault 凭据实验(U35)
仓库根目录的公开入口:
实验代码sh
python3 labs/cloud-adapters/run.py需要 Python 3.8+、JDK 21、Maven 3.9.x,以及两个真实服务端二进制(本入口不联网下载、不使用 Docker):
- Consul 1.22.7(HashiCorp 官方 darwin_arm64),默认路径
~/.cache/software-engineering-study/java-depth-impl/u35/upstream/consul-1.22.7/consul - Vault 2.2.1(官方 darwin_arm64),默认路径
~/.cache/software-engineering-study/java-depth-impl/u35/upstream/vault-2.2.1/vault
可用 U35_CONSUL_BIN / U35_VAULT_BIN 覆盖。runner 先执行 <binary> version 核对版本,再启动。
组件边界
| 组件 | 实际实现 |
|---|---|
| Consul | 真实 consul agent -dev 进程:独立 node、临时 data-dir、随机 loopback 端口(一次占 5 个:HTTP/gRPC/server RPC/serf LAN/serf WAN),DNS 关闭。数据为内存 + 临时目录,结束后删除 |
| Vault | 真实 vault server -dev 进程:随机 root token(运行时生成,不写入仓库/证据),随机 loopback 端口(dev 模式额外占 listen+1 作 Cluster Address),全内存存储 |
| 应用 | 每个用例一到两个完整 Spring Boot 应用 Context(真实 spring-cloud-starter-consul-discovery/config、spring-cloud-starter-vault-config 自动配置)。与 Consul/Vault 不同 OS 进程,但应用 Context 都在同一测试 JVM |
| 观察面 | 仅通过真实 HTTP:Consul 的 /v1/catalog/service、/v1/health/service?passing、/v1/agent/services、/v1/kv;Vault 的 `/v1/secret/data |
测试不 mock Spring Cloud 客户端,也不直接读应用内部 Bean;/probe 只输出普通配置值和秘密的 SHA-256 摘要,不回显秘密明文。强制约束:不使用默认端口 8500/8200;所有进程按 PID/端口/进程名定向清理,测试 JVM 的 shutdown hook 与 runner 台账双重兜底。
用例与观察行
测试类 study.cloudadapters.ConsulVaultHttpTest,观察行前缀 U35 ;名称与 runner 的 EXPECTED_TESTS/MARKERS 严格核对。
| 测试方法 | 行为与预期 | 观察行 |
|---|---|---|
consulDiscoveryRegistersAndQueriesRealAgent | 应用启动后自动注册到真实 dev agent;轮询 /v1/catalog/service 与 /v1/health/service?passing(默认 HTTP 健康检查 /actuator/health 每 10s,首次 passing 需等待)看到实例,/v1/agent/services 确认 agent 持有该实例 | U35 consulRegister=PASS |
consulKvConfigDataFeedsEnvironmentAtStartup | 预写 KV config/u35-consul-app/data(YAML 格式 data 键),应用经 spring.config.import=consul: 在启动 Environment 读到 lab.consul.note/revision | U35 consulConfig=PASS |
consulKvChangeRequiresExplicitReloadNotAutomatic | 显式关闭 spring.cloud.consul.config.watch.enabled=false:KV 改为 v2 后 3 秒观察窗口内 Environment 保持 v1,POST /actuator/refresh 后才读到 v2。Consul Config 默认的 Config Watch(periodic blocking query,默认 wait 55s + 固定 delay 1s)会通过 RefreshEvent 自动刷新——本用例关闭它以固定"显式刷新/重启才生效"的可重复语义;默认开启时自动刷新的行为由下方 RED 记录与源码说明承担,不计入最终用例 | U35 consulConfigNoAutoRefresh=PASS watchDisabled=true |
vaultKvV2SecretLoadsAndVersionRollbackIsVisible | Vault dev 写 KV v2 secret/u35-vault-app;应用经 spring.config.import=vault:// 读取(客户端自动探测 v2 并拼 data/);/probe 显示摘要而非明文;写第二版后未刷新前保持旧摘要,显式 refresh 后读到新摘要;?version=1 仍可读旧值,metadata current_version=2 | U35 vaultSecret=PASS |
vaultWrongBackendOrTokenFailsFast | 错 token + fail-fast=true:应用启动在 ConfigData 导入阶段抛出 VaultException(403 permission denied/invalid token),不产生可用 Context;错 backend(不存在的挂载)在非 leasing 读取路径下是 404 → 跳过,应用启动但秘密缺失(本版本实测边界,见下文源码依据) | U35 vaultFailure=PASS |
consulAgentOutageReflectsHealthDownAndRecovers | 停掉 Consul 后 /actuator/health 变为 DOWN(HTTP 503,含 consul 组件细节);重启同一实例后轮询恢复 UP。应用与 Consul 是独立进程,故障注入是真实进程停止而非代理模拟 | U35 consulOutage=PASS |
关键语义与源码依据(固定版本)
- Consul 配置不推送:
ConfigWatch(spring-cloud-consul-config5.0.4)以TaskScheduler的scheduleWithFixedDelay发起GET /v1/kv/{context}?recurse&wait=55s&index=...的 blocking query 轮询,索引变化时才发布RefreshEvent,由RefreshEventListener等价调用/refresh。它不是服务端推送,刷新延迟受 wait(55s)+delay(1s) 与响应时序影响。关闭watch.enabled后语义固定为"启动读取 + 显式刷新"。 spring.config.import=consul::ConsulConfigDataLocationResolver从spring.cloud.consul.host/port/scheme(或位置字符串中的 host:port)创建客户端;默认prefix=config、data-key=data、format=KEY_VALUE。本实验用format=yaml读取config/{app}/data的官方约定键。- Vault KV v2 自动探测:
spring-vault-core4.0.4 的KeyValueDelegate调sys/internal/ui/mounts/{path}判定 v2,再拼%sdata/%s;因此配置写spring.cloud.vault.kv.backend=secret,不要手写secret/data。 - Vault fail-fast 的两条路径:
spring.cloud.vault.config.lifecycle.enabled=false(本实验)走非 leasingVaultPropertySource,读取异常在failFast=true时直接抛出;VaultTemplate.read对 HTTP 404 返回null(HttpStatusUtil.isNotFound),因此错误挂载在读取语义下是跳过而非失败——只有权限/连接类错误才在导入阶段失败。默认 leasing 路径另有createLeasingPropertySourceFailFast在初始化时收集SecretLeaseErrorEvent,行为不同(本实验未运行 leasing 路径)。 - 注册健康检查:
ConsulAutoRegistration.createCheck默认注册http://{hostname}:{port}/actuator/health、interval=10s;因此?passing列表必须轮询等待首个检查周期。
RED 记录(TDD)
consulKvChangeRequiresExplicitReloadNotAutomatic初版按"KV 改变后不自动生效"在默认配置下断言,实测失败:默认 Config Watch 在 3 秒内即把revision从 v1 自动刷成 v2(一次性探针WatchRedProbeTest记录:actual={"note":"from-consul-v2","revision":"v2",...})。据此改为显式watch.enabled=false+ 观察行注明watchDisabled=true,并保留默认 watch 自动刷新的源码/文档依据,不把"不存在自动刷新"写成结论。RED 探针在记录后删除,最终测试类固定 6 个用例。consulDiscoveryRegistersAndQueriesRealAgent初版对 Consul JSON 按紧凑格式断言,失败于实际输出冒号后带空格("ServiceAddress": "127.0.0.1");改为压缩空白后断言。
运行与证据
runner 依次:核对 POM(Boot 4.0.9 / Cloud 2025.1.4 / Java 21)→ JDK 21 与 Maven 3.9.x → 两个服务端二进制版本(Consul v1.22.7、Vault v2.2.1)与 SHA-256 → 单模块 clean test(禁止跳过/忽略失败,900s 超时)→ Surefire 报告(6 用例、failures/errors/skipped=0、类名与名称集合)→ 6 条观察行唯一性 → 按测试台账定向清理并断言 pid 消失、端口释放、进程名无残留 → 依赖版本核对 → 运行期文件哈希不变。
- 证据固定写入 evidence/u35-2026-10-27.json:先
running,结束原子替换passed/failed;含时间、输入、每条命令与退出码、观察行、实际依赖版本、Surefire 计数与 SHA-256、二进制版本与 SHA-256、清理台账。 - 原始日志、命令状态、Surefire 副本、依赖清单与
agents.jsonl台账在仓库外:~/.cache/software-engineering-study/java-depth-impl/u35/run-<随机目录>/ - 超时返回 124,SIGINT/SIGTERM 返回 130/143;runner 的进程组先 TERM 再 KILL,Consul/Vault 按台账 PID + 端口 + 进程名断言清理。Vault root token 为运行时随机值,只存在于测试 JVM 内存与 Vault dev 自身日志(不落入仓库文件,证据不引用)。
- 失败会覆盖上一轮成功状态;强杀 runner 时可能只留下
running,不能视为成功。
未运行范围
- Kubernetes、Netflix Eureka、ZooKeeper 的适配(配置/语义差异只在讲义推演,未实跑;ZooKeeper 运行时归 U39,Nacos 运行时归 U40)。
- TLS/mTLS、ACL token、Consul 集群/quorum、Vault 生产模式(持久化存储、unseal、HA)、Vault 动态凭据与 leasing 生命周期、数据库/AWS/Kubernetes 等 secret backend。
- 独立部署的客户端 OS 进程、跨主机网络分区、滚动发布与生产容量。
- Consul Config Watch 默认开启时的自动刷新只由一次性 RED 观察 + 源码/文档说明,不作为最终用例的稳定断言(时序依赖 wait 55s 的 blocking query)。
- 应用的
/probe摘要只用于证明装载/变更可见性,不构成生产密钥管理或安全审计。
Consul/Vault 均为 dev 模式单节点、合成数据;上述未运行项不能因本实验通过而视为已验证。