第一章:CNCF Go文件上传最佳实践概览
在云原生生态中,遵循CNCF(Cloud Native Computing Foundation)原则的Go语言服务需兼顾安全性、可观测性、可扩展性与标准化。文件上传作为高频交互场景,其设计必须规避常见陷阱——如内存溢出、MIME类型绕过、路径遍历及缺乏审计追踪。
安全边界控制
始终限制单次上传大小与总内存占用。使用 http.MaxBytesReader 包裹请求体,并结合 multipart.Reader 流式解析:
// 设置全局上传上限(例如 10MB)
const maxUploadSize = 10 << 20 // 10 MB
func uploadHandler(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
// 后续调用 r.ParseMultipartForm 自动应用内存限制
}
该机制在读取阶段即中断超限请求,避免OOM风险。
内容验证策略
拒绝未经校验的原始文件名与类型:
- 使用
filepath.Base()提取安全文件名; - 通过
mime.TypeByExtension()+bytes.HasPrefix()双重校验实际内容(Magic Number); - 禁止
.htaccess、.php等危险扩展名(建议白名单:.jpg,.png,.pdf,.zip)。
存储与生命周期管理
| 组件 | 推荐方案 | 说明 |
|---|---|---|
| 临时存储 | 内存缓冲(/tmp 临时目录 | 避免直接写入持久卷,防止磁盘填满 |
| 持久化后端 | 对象存储(S3兼容API) | 利用MinIO或AWS S3,支持版本控制与加密 |
| 清理机制 | 基于TTL的异步GC任务 | 通过Kubernetes CronJob定期清理7天前未确认的临时文件 |
可观测性集成
注入OpenTelemetry追踪上下文,记录关键指标:
http_upload_size_bytes(直方图,按bucket分组)http_upload_status_count(标签:status="success"/"blocked"/"error")- 上传耗时P95/P99延迟
所有上传操作必须生成结构化日志,包含 request_id、file_hash(SHA256)、content_type 和 storage_path,便于审计溯源。
第二章:RFC 7578核心规范在Go中的映射实现
2.1 multipart/form-data边界解析与Go标准库multipart.Reader的合规性校验
multipart/form-data 的核心在于边界(boundary)的严格语法:必须以 -- 开头,后接至少1个非空白字符,且不得包含 CR/LF 或双引号。Go 标准库 mime/multipart.Reader 对此有隐式校验。
边界格式合规性要求
- 必须符合 RFC 7578 §4.1:
boundary参数值需满足token规则(a-z A-Z 0-9 ! # $ % & ' * + - ^ _{ | } ~ .` - 实际解析中,
multipart.NewReader会拒绝含\r,\n,\0,"的 boundary
Go multipart.Reader 的边界验证逻辑
// 源码简化示意(src/mime/multipart/reader.go)
func NewReader(r io.Reader, boundary string) *Reader {
if len(boundary) == 0 || len(boundary) > 70 {
return nil // 长度越界直接拒绝
}
for _, b := range boundary {
if !validBoundaryByte(b) { // 检查每个字节是否在 token 字符集内
return nil
}
}
return &Reader{...}
}
该逻辑确保边界不引入解析歧义——若 boundary 含换行符,后续 NextPart() 将无法正确切分段落,导致 io.ErrUnexpectedEOF。
常见非法 boundary 示例对比
| boundary 值 | 是否被 multipart.NewReader 接受 |
原因 |
|---|---|---|
----xyz123 |
✅ | 符合 token 规则,长度合法 |
abc def |
❌ | 含空格(非 token 字符) |
boundary\r\n |
❌ | 含 \r,触发 validBoundaryByte 失败 |
graph TD
A[HTTP请求含Content-Type] --> B{提取boundary参数}
B --> C[长度检查: 0 < len ≤ 70]
C --> D[逐字节token合法性校验]
D -->|全部通过| E[初始化Reader]
D -->|任一失败| F[返回nil]
2.2 字段名与文件名UTF-8编码处理:Go strings包与unicode/norm的协同实践
Go 默认以 UTF-8 存储字符串,但 Unicode 等价性(如 é 可表示为 U+00E9 或 U+0065 U+0301)易引发字段名/文件名比对失败。
归一化是关键前提
需统一使用 NFC(标准组合形式)确保等价字符序列一致:
import "golang.org/x/text/unicode/norm"
func normalizeName(s string) string {
return norm.NFC.String(s) // 强制转为标准组合形式
}
norm.NFC.String() 将分解字符(如 e + ◌́)合并为单码点 é,避免 os.Open("café.txt") 与 cafe\u0301.txt 被视为不同文件。
实际校验场景对比
| 原始字符串 | NFC 归一化后 | 是否相等 |
|---|---|---|
"café" |
"café" |
✅ |
"cafe\u0301" |
"café" |
✅ |
文件系统兼容性要点
- Linux ext4、macOS APFS 均支持 UTF-8,但 macOS HFS+ 对 NFC 有隐式强制
- Windows NTFS 使用 UTF-16,Go
os包自动转换,但仍建议归一化后再调用
graph TD
A[原始UTF-8字符串] --> B{是否已NFC?}
B -->|否| C[norm.NFC.String]
B -->|是| D[安全用于字段映射/文件I/O]
C --> D
2.3 Content-Disposition头字段的结构化解析与go-mime/multipart的扩展封装
Content-Disposition 是 multipart 消息中标识字段语义的关键头,其典型值如:
Content-Disposition: form-data; name="file"; filename="report.pdf"
解析模型设计
标准 RFC 7578 要求解析 name、filename、filename*(RFC 5987 编码)等参数,需兼顾 ASCII 与 UTF-8 兼容性。
go-mime/multipart 扩展封装要点
- 封装
Part.Header的惰性解析逻辑,避免重复正则匹配 - 支持
filename*的 IRI 解码(如"UTF-8''%E6%8A%A5%E5%91%8A.pdf"→"报告.pdf") - 提供
Part.Filename()方法统一返回解码后文件名
// 扩展 Part 类型的 Filename 方法
func (p *Part) Filename() (string, error) {
if raw := p.Header.Get("Content-Disposition"); raw != "" {
params, err := parseDispositionParams(raw) // 内部解析器,支持 quoted-string 与 token
if err != nil { return "", err }
if fn := params["filename*"]; fn != "" {
return irt.Decode(fn) // RFC 5987 解码
}
return params["filename"], nil
}
return "", nil
}
该实现规避了
net/http原生Part.FormName()的局限性,将Content-Disposition参数提取从字符串切片提升为结构化键值映射。参数解析器自动处理引号、空格、分号分隔及编码转义,确保filename*和filename语义一致性。
2.4 文件元数据(filename、filename*、name)的优先级解析逻辑与Go反射验证策略
HTTP multipart 表单中,filename、filename* 和 name 字段共存时需严格遵循 RFC 5987 / RFC 2388 优先级规则:
filename*(带字符集编码)优先级最高,支持 UTF-8 编码(如filename*=UTF-8''%E6%96%87%E4%BB%B6.pdf)- 其次为
filename(ASCII-only,无编码) name仅标识表单项名,不参与文件名解析
优先级判定流程
graph TD
A[解析Header] --> B{存在 filename*?}
B -->|是| C[URLDecode + charset decode → Unicode filename]
B -->|否| D{存在 filename?}
D -->|是| E[直接取值,忽略非ASCII字符]
D -->|否| F[回退至 name 或空字符串]
Go 反射驱动的字段校验示例
// 使用 reflect.Value 获取 multipart.FileHeader 字段值
func getFilenamePriority(fh *multipart.FileHeader) string {
v := reflect.ValueOf(*fh).FieldByName("FileName") // filename*
if !v.IsValid() {
return fh.Filename // fallback to filename field
}
return v.String() // raw decoded value
}
FileName字段由net/http在ParseMultipartForm中依据 RFC 自动解码filename*并覆盖Filename;反射访问确保绕过 public 方法封装,直击底层解析结果。
关键参数说明
| 字段 | 编码要求 | 是否参与文件名推导 | Go 结构体字段 |
|---|---|---|---|
filename* |
RFC 5987 | ✅ 最高优先级 | FileHeader.FileName |
filename |
ASCII only | ✅ 次优先级 | FileHeader.Filename |
name |
任意字符串 | ❌ 仅表单键名 | Part.FormName() |
2.5 多部分边界嵌套与空行处理:Go bufio.Scanner的RFC严格模式配置
RFC 7578(multipart/form-data)要求边界(boundary)可嵌套,且空行必须严格区分头部与正文。bufio.Scanner 默认行为不满足该规范。
边界识别的陷阱
默认 SplitFunc 无法处理嵌套边界(如 boundary="A" 内含 boundary="B"),且将连续空行视为单一分隔符。
自定义 SplitFunc 实现
func rfc7578Split(buf []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(buf) == 0 {
return 0, nil, nil
}
// 查找 CRLF 后紧跟 "--" + boundary 的完整行(含末尾 CRLF)
if i := bytes.Index(buf, []byte("\r\n--")); i >= 0 {
return i + 2, buf[:i], nil // 截断至前一CRLF,保留完整段头
}
if atEOF {
return len(buf), buf, nil
}
return 0, nil, nil
}
逻辑分析:该函数确保仅在 \r\n--{boundary} 起始位置切分,避免误判嵌套边界中的 --;advance = i + 2 精确跳过 \r\n,防止空行被吞。
RFC严格模式关键参数对照
| 参数 | 默认值 | RFC 7578 要求 | Scanner 配置 |
|---|---|---|---|
| 最大令牌长度 | 64KB | 无硬限制(但需防 DoS) | Scanner.Buffer(..., 1<<20) |
| 空行处理 | 合并连续空行 | 必须保留首空行作为 body 分隔符 | 依赖自定义 SplitFunc |
graph TD
A[读取原始字节流] --> B{是否匹配 \\r\\n--boundary?}
B -->|是| C[切分:保留前导空行]
B -->|否| D[继续缓冲直到匹配或 EOF]
C --> E[返回完整 multipart 段]
第三章:Go HTTP服务端上传处理的合规加固
3.1 net/http.Request.Body的流式读取与RFC 7578分块长度约束的实时检测
HTTP multipart/form-data 请求中,net/http.Request.Body 是一个阻塞式 io.ReadCloser,其底层 multipart.Reader 必须严格遵循 RFC 7578 对 boundary 分隔符后 CRLF 及 chunk 长度的约束。
流式边界校验机制
需在首次 Read() 前预检前 128 字节,验证是否存在合法 --boundary\r\n 开头及后续 Content-Disposition 格式:
buf := make([]byte, 128)
n, _ := io.ReadFull(req.Body, buf) // 非阻塞预读
if !bytes.HasPrefix(buf[:n], []byte("--"+boundary+"\r\n")) {
http.Error(w, "invalid boundary prefix", http.StatusBadRequest)
}
此处
io.ReadFull确保至少读满缓冲区或返回io.ErrUnexpectedEOF;boundary来自req.MultipartReader().Boundary(),必须在解析前提取。
RFC 7578 关键约束表
| 约束项 | 允许长度 | 违规后果 |
|---|---|---|
| Boundary 长度 | ≤ 70 字节 | 400 Bad Request |
| 单个 part header 行 | ≤ 1024 字节 | 400 或截断解析 |
Content-Transfer-Encoding |
仅 binary/7bit/8bit |
忽略非法值 |
实时长度检测流程
graph TD
A[Read chunk] --> B{Length > 1024?}
B -->|Yes| C[Reject with 400]
B -->|No| D[Parse headers]
D --> E{Valid C-D header?}
E -->|No| F[Abort stream]
3.2 文件头嗅探(MIME类型识别)与Go mime.TypeByExtension的白名单校验机制
文件上传安全的第一道防线常依赖于双重校验:扩展名映射与二进制头部(magic bytes)分析。mime.TypeByExtension 仅基于后缀查表,不验证内容,属于白名单式静态映射:
// Go 标准库中 mime.TypeByExtension 的典型用法
ext := ".pdf"
mimeType := mime.TypeByExtension(ext) // 返回 "application/pdf" 或 ""
if mimeType == "" {
log.Println("扩展名未注册,拒绝上传")
}
该函数内部维护一个有限白名单(如 .jpg, .png, .txt),未注册扩展名一律返回空字符串——这是有意为之的安全设计,而非缺陷。
常见扩展名与对应 MIME 类型白名单节选:
| 扩展名 | MIME 类型 | 是否默认启用 |
|---|---|---|
.png |
image/png |
✅ |
.html |
text/html |
✅ |
.exe |
未注册,返回 “” | ❌ |
.php |
未注册,返回 “” | ❌ |
实际生产中需结合 net/http.DetectContentType 进行头部嗅探,形成“扩展名初筛 + 文件头复核”双校验流程:
graph TD
A[客户端上传文件] --> B{检查扩展名}
B -->|白名单内| C[调用 mime.TypeByExtension]
B -->|白名单外| D[直接拒绝]
C --> E[读取前512字节]
E --> F[net/http.DetectContentType]
F --> G[比对 MIME 是否匹配预期]
3.3 上传临时存储路径的POSIX兼容性设计与Go os.TempDir()的安全调用范式
POSIX路径语义一致性要求
临时路径必须满足:无符号扩展、无空格/控制字符、支持/分隔符、长度≤255字节。os.TempDir()返回值在Linux/macOS/Windows(WSL)下天然符合POSIX路径规范,但在Windows原生环境下返回\分隔路径——需显式标准化:
import "path/filepath"
tmpDir := filepath.ToSlash(os.TempDir()) // 统一为正斜杠
if !strings.HasPrefix(tmpDir, "/") && runtime.GOOS != "windows" {
panic("non-POSIX temp dir detected")
}
filepath.ToSlash()仅转换分隔符,不改变语义;os.TempDir()本身不保证可写,须配合os.Stat()校验权限。
安全调用三原则
- ✅ 始终校验返回路径是否为绝对路径
- ✅ 创建子目录前调用
os.MkdirAll(tmpDir, 0700)设最小权限 - ❌ 禁止拼接用户输入到临时路径(防路径遍历)
| 风险点 | 安全对策 |
|---|---|
| 权限宽松 | 0700而非0755 |
| 目录竞争(TOCTOU) | 使用os.MkdirAll+os.Chmod原子组合 |
graph TD
A[os.TempDir()] --> B{Is absolute?}
B -->|No| C[Panic]
B -->|Yes| D[filepath.ToSlash]
D --> E[os.MkdirAll with 0700]
第四章:生产级上传组件的构建与验证
4.1 基于Go embed与go:generate的RFC 7578测试向量生成器开发
RFC 7578(multipart/form-data)规范要求严格覆盖边界场景,手动构造测试用例易出错且难以维护。我们采用 go:embed 静态嵌入原始测试模板,配合 go:generate 自动化生成可执行测试向量。
核心设计思路
- 模板文件存于
testdata/templates/,含占位符如{{BOUNDARY}}和{{FILENAME}} embed.FS安全加载模板,避免运行时路径依赖go:generate触发gen_vectors.go,遍历参数组合生成.http和.golden文件
参数化生成逻辑
// gen_vectors.go
//go:generate go run gen_vectors.go
func main() {
fsys := embed.FS{...} // 嵌入 testdata/templates/
templates, _ := fs.Glob(fsys, "templates/*.tmpl")
for _, t := range templates {
data := map[string]string{
"BOUNDARY": uuid.New().String(),
"FILENAME": "test.bin",
"CONTENT_TYPE": "application/octet-stream",
}
tmpl := template.Must(template.ParseFS(fsys, "templates/*"))
var buf strings.Builder
tmpl.ExecuteTemplate(&buf, t, data) // 渲染为完整 multipart 流
os.WriteFile("testdata/vectors/"+t+".http", buf.Bytes(), 0644)
}
}
该脚本动态注入边界、编码、空字段等 RFC 7578 关键变量,确保覆盖 CRLF、quoted-printable、binary 等子类型。
生成策略对照表
| 场景 | Boundary 类型 | 文件名编码 | 是否含空字段 |
|---|---|---|---|
| Basic | ASCII | US-ASCII | 否 |
| Unicode | UTF-8 | UTF-8 | 是 |
| Edge | 70-char random | Quoted-Printable | 是 |
graph TD
A[go:generate] --> B[读取 embed.FS 模板]
B --> C[参数组合迭代]
C --> D[渲染 multipart 流]
D --> E[写入 .http + .golden]
4.2 go-cnf/certify工具链集成:自动化检测multipart解析偏差的CLI实践
go-cnf/certify 提供 certify multipart --audit 子命令,专用于识别不同 HTTP 服务端对 multipart/form-data 边界解析的不一致行为。
快速启动检测
certify multipart --audit \
--target https://api.example.com/upload \
--fuzz-boundary "----BOUNDARY-XYZ" \
--expect-status 400,415
--target:待测服务端地址;--fuzz-boundary:注入非标准边界字符串触发解析歧义;--expect-status:声明预期的异常响应码集合,偏离即视为偏差。
检测结果概览(示例)
| 解析引擎 | 边界校验严格性 | 是否接受空行前缀 | 偏差标识 |
|---|---|---|---|
| Go net/http | 强 | 否 | ✅ |
| Nginx + PHP-FPM | 宽松 | 是 | ⚠️ |
核心检测流程
graph TD
A[生成变异multipart载荷] --> B[注入非法boundary/headers]
B --> C[并发发送至多目标]
C --> D[比对响应状态与body指纹]
D --> E[标记解析偏差服务]
4.3 Prometheus指标注入:Go http.Handler中间件中RFC合规性计数器埋点
核心设计原则
遵循 RFC 7231 对HTTP状态码语义的定义,将响应码归类为 success(2xx)、client_error(4xx)、server_error(5xx)三类,避免简单按数字区间硬编码。
指标定义与注册
var (
httpStatusCounter = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "http_status_total",
Help: "Total HTTP requests by status class and method, RFC 7231-compliant",
},
[]string{"class", "method"},
)
)
func init() {
prometheus.MustRegister(httpStatusCounter)
}
逻辑分析:
class标签值取"success"/"client_error"/"server_error",而非原始状态码,确保语义一致性;method保留原始 HTTP 方法(如GET),支持方法维度下钻。注册在init()中保证单例唯一性。
中间件实现片段
func MetricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rw := &responseWriter{ResponseWriter: w, statusCode: 200}
next.ServeHTTP(rw, r)
class := statusClass(rw.statusCode)
httpStatusCounter.WithLabelValues(class, r.Method).Inc()
})
}
状态分类映射表
| 状态码范围 | RFC语义类别 | 示例 |
|---|---|---|
| 200–299 | success |
200, 201, 204 |
| 400–499 | client_error |
400, 401, 404 |
| 500–599 | server_error |
500, 502, 503 |
流程示意
graph TD
A[HTTP Request] --> B[Wrap ResponseWriter]
B --> C[Call Handler]
C --> D[Capture statusCode]
D --> E[RFC Class Mapping]
E --> F[Increment Counter]
4.4 Kubernetes Ingress网关层与Go上传服务的RFC一致性对齐方案
为确保文件上传路径严格符合 RFC 7230/7231 对 Content-Length、Transfer-Encoding 及 Expect: 100-continue 的语义约束,Ingress 层需协同 Go 服务完成协议级对齐。
协议握手增强机制
Nginx Ingress Controller 启用 proxy_force_ranges off 并显式透传 Expect 头:
# ingress-nginx configmap snippet
proxy_pass_request_headers on;
proxy_set_header Expect $http_expect;
proxy_buffering off; # 避免缓冲破坏分块语义
此配置确保
Expect: 100-continue不被拦截,Go HTTP server 能触发标准预检响应(100 Continue),避免客户端因超时重发导致重复上传。
Go服务端RFC校验逻辑
func handleUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "multipart/form-data" {
http.Error(w, "RFC 7231 violation", http.StatusBadRequest)
return
}
// 强制校验 Content-Length(非分块上传必须存在)
if r.TransferEncoding == nil && r.ContentLength < 0 {
http.Error(w, "Missing Content-Length", http.StatusBadRequest) // RFC 7230 §3.3.3
return
}
// ...
}
Go 服务拒绝无
Content-Length的非分块请求,符合 RFC 7230 关于消息长度确定性的强制要求;同时保留对chunked编码的兼容性。
| 对齐维度 | Ingress 行为 | Go 服务行为 |
|---|---|---|
Expect 处理 |
透传不拦截 | 触发 100 Continue 或 417 |
Content-Length |
禁用自动重写 | 拒绝缺失值的非分块请求 |
| 分块边界 | proxy_buffering off |
r.Body 直接流式解析 |
graph TD
A[Client Upload] --> B{Ingress}
B -->|透传 Expect/Content-Length| C[Go Server]
C -->|RFC 7230 校验| D[Accept/Reject]
D -->|Success| E[Streaming Parse]
第五章:附录:CNCF认证工具链与版本兼容性矩阵
官方认证工具链范围界定
CNCF官方认证的工具链涵盖Kubernetes一致性测试套件(k8s-conformance)、Certified Kubernetes Installer(如RKE2、OpenShift、EKS Optimized AMI)、以及配套验证组件(Sonobuoy v0.56.1+、Kubetest2)。截至2024年Q3,CNCF仅对通过k8s-conformance-v1.28+流程验证的发行版授予“Certified Kubernetes”徽标,且要求所有测试项(共257个e2e用例)100%通过,包括Service, Ingress, CSI, PodSecurityPolicy(已弃用但兼容性仍需显式声明)等关键能力域。
主流发行版与Kubernetes版本兼容快照
以下为2024年9月实测验证的兼容性矩阵(基于CNCF公开认证报告及内部CI复现):
| 工具/发行版 | 支持K8s版本 | Sonobuoy最低要求 | CSI驱动兼容性 | 备注 |
|---|---|---|---|---|
| RKE2 v1.28.11+rke2r1 | 1.28.x | v0.56.3 | v1.8.0+(hostpath, aws-ebs, ceph-csi) | 需启用--disable-cloud-provider绕过AWS IAM冲突 |
| OpenShift 4.14.12 | 1.27.8 (OCP内核) | v0.55.0 | v1.7.0(rook-ceph 1.12.3集成) | 默认禁用PodSecurityAdmission,需手动启用以通过PSP替代测试 |
| EKS AMI 20240715 | 1.29.3 | v0.57.0 | v1.10.0(ebs-csi-driver v1.26.1) | 必须使用eksctl 0.184.0+部署,否则conformance-test Job因IRSA权限缺失失败 |
实战案例:金融客户多集群升级踩坑分析
某城商行在将生产集群从Kubernetes 1.25.12(RKE2 v1.25.12+rke2r1)升级至1.28.11时,发现Sonobuoy v0.55.0无法完成volume-expand测试。根因是其定制CSI插件未实现ControllerExpandVolume RPC——该接口在1.28中成为强制要求。解决方案:将CSI driver升级至v1.8.2,并在StorageClass中显式设置allowVolumeExpansion: true,同时修改RBAC规则新增expandvolumes verb。该修复耗时3.5人日,涉及7个微服务的StatefulSet滚动重启策略调整。
版本锁死风险与自动化检测脚本
CNCF认证要求工具链各组件版本形成闭环依赖。例如:k8s-conformance-v1.28.11严格绑定kubetest2 v0.12.0(非v0.11.x或v0.13.0),因v0.12.0修正了--provider=local下etcd snapshot校验逻辑缺陷。以下为生产环境CI中嵌入的兼容性校验片段:
#!/bin/bash
K8S_VERSION=$(kubectl version --short | grep Server | awk '{print $3}')
SONOBUOY_VERSION=$(sonobuoy version | grep Version | awk '{print $2}')
if [[ "$K8S_VERSION" == "v1.28."* ]] && [[ "$SONOBUOY_VERSION" != "v0.56."* && "$SONOBUOY_VERSION" != "v0.57."* ]]; then
echo "ERROR: Sonobuoy $SONOBUOY_VERSION incompatible with $K8S_VERSION"
exit 1
fi
CNCF认证状态实时查询方法
直接调用CNCF官方API获取最新认证状态:
curl -s "https://landscape.cncf.io/data.json" | \
jq -r '.items[] | select(.cncf === true and .kubernetes_version == "1.28") | .name + " → " + .github_url'
返回示例:RKE2 → https://github.com/rancher/rke2。该数据每6小时同步一次,覆盖全部127个认证发行版。
Mermaid兼容性依赖图谱
graph LR
A[Kubernetes v1.28.11] --> B[Sonobuoy v0.56.3]
A --> C[kubetest2 v0.12.0]
B --> D[Conformance Plugin v0.2.0]
C --> E[Provider Local v0.3.1]
D --> F[etcd v3.5.10]
F --> G[Go 1.21.10]
style A fill:#4285F4,stroke:#333
style B fill:#34A853,stroke:#333 