feat: complete E2E deployment flow with storage layered config and values template versioning

- Instance deployment: charts browser, deploy modal, instances list
- Values Template version management (create/history/rollback)
- Storage layered config (cluster > workspace > shared priority)
- Cluster credential decryptIfNeeded for mixed encrypted/plaintext kubeconfig
- YAML syntax validation (client-side + server-side warning)
- Frontend: charts, instances, storage, templates, admin pages
- Backend: storage service, instance service, cluster service, helm client
- Multi-Tenant Kubeconfig.md: added by user
This commit is contained in:
Ivan087
2026-04-30 16:31:00 +08:00
parent 985369d40f
commit 47849042a7
42 changed files with 2029 additions and 255 deletions

View File

@ -24,4 +24,124 @@
**根因**: 代码中的 InitSchema 与实际 init-db.sql 不同步
**影响**: GetByID/GetByName 查询时字段数不匹配会报错
**Fix**: 修复 GetByID/GetByName 的查询和 Scan使用实际的 DB schema
**How to apply**: InitSchema() 和实际 DB schema 必须保持同步
**How to apply**: InitSchema() 和实际 DB schema 必须保持同步
## Bug 5: Frontend CreateInstanceRequest Missing registryId Field (2026-04-17)
**现象**: Charts 页面 Deploy Modal 填好信息后点击 Deploy后端报错无法找到 registry
**根因**: Frontend `CreateInstanceRequest` type 缺少 `registryId` 字段,`charts/page.tsx` 发送 `registry_id`,但后端 DTO 用 `registryId` 接收
**修复**:
1.`frontend/src/lib/types.ts``CreateInstanceRequest` 添加 `registryId: string`
2.`frontend/src/app/charts/page.tsx` 发送 `registryId: selectedRegistry?.id` 而非 `registry_id`
**How to apply**: 前后端 API 类型定义必须保持同步,每次添加新字段时验证两端的字段名一致
## Bug 6: Artifact Filter Mismatch (2026-04-17)
**现象**: Charts 页面选择 repo 后显示 "No versions found",但 API 实际返回了 artifacts
**根因**: 前端过滤逻辑 `a.mediaType?.includes('chart')` 无法匹配 API 返回的 `mediaType: "application/vnd.oci.image.manifest.v1+json"`
**影响**: 用户看不到任何 helm chart 版本
**修复**: 恢复过滤逻辑为 `a.type === 'chart'`API 返回的 type 字段确实为 "chart"
**How to apply**: API 字段的实际值必须与过滤逻辑匹配。type 字段比 mediaType 更可靠
## Bug 7: Docker Node.js Version Mismatch (2026-04-17)
**现象**: 本地 node v12 运行 npm run build 报错 "Unexpected token '?'"
**根因**: Next.js 需要 Node.js 14+,本地环境太旧
**修复**: 使用 Docker 容器中的 node:20-alpine 构建前端
**How to apply**: 前端构建使用 Docker 而非本地环境,避免 Node.js 版本问题
## Lesson 8: Circular Dependency via Setter Injection (2026-04-17)
**场景**: StorageService 和 InstanceService 在同一 packagemain.go 中 StorageService 先创建InstanceService 后创建且需要引用 StorageService
**方案**: 使用 setter 方法 `SetStorageService()` 而非构造函数注入
**Why**: Go 不允许循环依赖,但可以通过 setter 在创建后再建立引用关系
**How to apply**: 多个 service 需要相互引用时,用 setter 方法注入,避免循环 import
## Lesson 9: Playwright networkidle Timeout on Real-time Apps (2026-04-17)
**现象**: Playwright `wait_for_load_state('networkidle')` 超时 30s
**根因**: 页面有 WebSocket/real-time 连接,永远不会达到 `networkidle` 状态
**修复**: 使用 `wait_for_load_state('load')` + `wait_for_timeout()` 替代
**How to apply**: 测试有 WebSocket/SSE/real-time 连接的 Next.js 页面时,用 `load` 而非 `networkidle`
## Lesson 10: Docker Backend 重启用 docker cp (2026-04-17)
**现象**: `docker compose build backend` 报错找不到服务(不同 compose context
**方案**: 用 `docker cp` 直接复制新 binary 到运行中的容器,然后用 `docker restart` 重启
**命令**:
```bash
docker cp backend/ocdp-backend ocdp-backend:/app/ocdp-backend
docker restart ocdp-backend
```
**How to apply**: 快速热更新 Docker 容器内的 Go binary 时,用 docker cp + docker restart 而非 rebuild image
## Lesson 11: Goroutine 启动前的状态准备 (2026-04-17)
**场景**: InstanceService.CreateInstance 启动 goroutine 异步执行 Helmstorage resolution 需要在 goroutine 启动前完成
**方案**: 在 `go s.executeAndSyncInstall(...)` 之前完成 storage 解析和 values merge
**Why**: goroutine 启动后,主 goroutine 的变量修改不会反映到子 goroutine但 instance 是从 DB 重新读取的,所以 storage 已在 DB 的 instance.Values 中)
**How to apply**: 需要在异步操作中使用的数据,必须在 goroutine 启动前持久化到 DB 或注入到 goroutine 可访问的上下文
## Lesson 12: DB Unique Constraint 必须是 Row-based Versioning (2026-04-17)
**现象**: Values Template Update 报错 `pq: duplicate key violates unique constraint "values_templates_workspace_id_chart_reference_id_name_key"`
**根因**: `init-db.sql` 的 unique constraint 缺少 `version` 列 (`UNIQUE(workspace_id, chart_reference_id, name)`),但 `ValuesTemplateRepository.Update()` 实现的是行式版本化——每次 UPDATE 实际 INSERT 一行新版本,导致新行 version+1 与旧约束冲突
**修复**: 修正 `init-db.sql` 约束为 `UNIQUE(workspace_id, chart_reference_id, name, version)`,在运行中的 DB 执行 ALTER TABLEPostgreSQL 自动截断长约束名到 63 字符)
**How to apply**: 任何行式版本化(每次更新 INSERT 新行的表unique constraint 必须包含 version 列。建表前先确认 Repo 层的 UPDATE 语义。
## Lesson 13: Go JSON Tag 字段名决定 API 请求格式 (2026-04-17)
**现象**: Values Template Update v2 返回 `values_yaml: ""`(空值)
**根因**: DTO 使用 `json:"values_yaml"`snake_case但前端请求发送了 camelCase `valuesYaml`。Go 的 json tag 是精确匹配的
**修复**: 前端请求使用正确的 snake_case 字段名 `values_yaml`
**How to apply**: 前后端字段名必须严格一致。Go DTO 的 json tag 即 API 契约,不能臆测 camelCase/snake_case 映射
## Lesson 14: API 子资源路由必须在正确路径下 (2026-04-17)
**现象**: History 和 Rollback API 返回 404 "page not found"
**根因**: 这两个 API 是 Chart Reference 的子资源(`/chart-references/{id}/values-templates/history`),而非独立资源(`/values-templates/history`
**修复**: 使用正确的子资源路径调用 API
**How to apply**: RESTful API 中子资源nested resource的路由必须是 `/parent/{id}/child/action`。测试时先查 handler 路由注册确认路径
## Bug 15: values.yaml 未应用到 Helm Releases (2026-04-22)
**现象**: 前端发送 values.yaml自定义值`replicaCount: 9`)没有应用到集群中的 Helm release
**根因**: `Instance.SetValuesYAML()` 只存储了 `i.ValuesYAML = yaml`**没有解析成 `i.Values` map**。Helm Client 使用 `install.Run(chart, instance.Values)``instance.Values` 是空 map
**修复**:
1. `instance.go`: 在 `SetValuesYAML()` 中添加 `gopkg.in/yaml.v3` 解析,将 YAML string 解析为 `map[string]interface{}` 并 merge 到 `Values`
2. `instance_handler.go`: `ListInstances``InstanceResponse` 构造中缺少 `Values` 字段(只有 `GetInstance` 等有),添加 `Values: instance.Values`
**Why**: values.yaml 存储在 DB 中但 Helm SDK 需要 `map[string]interface{}`。YAML parse 是必须的中间步骤
**How to apply**: 任何存储结构与下游使用格式不同时(如 YAML string → map必须在存储/设置时就做转换,而非依赖下游处理
## Lesson 16: Go `:=` 会遮蔽而非重用变量 (2026-04-22)
**现象**: 在 `getActionConfig` 函数内添加日志时Go 报错 "no new variables on left side of :="
**根因**: 尝试 `var err error; ...; actionConfig, err := getActionConfig(...)``err` 已声明,不能再用 `:=`。只有 `actionConfig` 是新变量
**修复**: `actionConfig, err := getActionConfig(...)` 即可,因为 actionConfig 是新的。`err` 必须用普通 `=` 赋值
**How to apply**: Go 的 `:=` 用于声明新变量并初始化。若变量已用 `var` 声明,再用 `:=` 会报 "no new variables"。此时用 `var err error` 声明后,在同一作用域用 `=` 赋值
## Lesson 17: Playwright 按钮文本匹配需精确 (2026-04-22)
**现象**: Playwright `filter(has_text='nginx\n')` 找不到 charts/nginx 按钮
**根因**: Next.js `inner_text()` 返回按钮内所有文本节点的拼接,实际为 `'nginx\n\ncharts/nginx'`(两个换行),并非单个换行
**修复**: 使用 `txt.strip().startswith('nginx') and 'nginx-custom' not in txt_raw` 精确匹配,避免误选 `nginx-custom`
**How to apply**: 复合文本(多个子元素)的 Playwright `inner_text()` 行为与预期不同。始终使用范围匹配而非精确匹配,并在匹配条件中排除负面模式
## Lesson 18: Python 输出缓冲导致 nohup 后日志为空 (2026-04-22)
**现象**: `nohup python3 test.py > log &``cat log` 显示空文件,但进程正在运行
**根因**: Python 默认启用输出缓冲nohup 不会自动 flush
**修复**: 使用 `python3 -u test.py` (unbuffered mode) 或 `python3 -W- test.py | tee log` 管道输出
**How to apply**: 运行长后台 Python 进程时,始终加 `-u` 参数或使用 `tee` 管道,避免输出被缓冲导致无法实时监控
## Lesson 19: YAML 解析失败静默忽略导致 values 未应用 (2026-04-22)
**现象**: 用户在 values.yaml textarea 输入 `replicaCount=4`等号helm get values 显示未应用;但 placeholder 显示正确语法 `replicaCount: 2`(冒号)
**根因**: `yaml.Unmarshal("replicaCount=4", ...)` 解析失败返回空 map`if err == nil && parsed != nil` 条件不满足,**静默跳过** mergeHelm 收到空 values
**修复**:
1. Backend `SetValuesYAML()`: 解析失败时打印 WARNING 日志
2. Frontend: textarea onChange 做客户端 YAML 语法检查(检测 `key=value` 模式),红色错误提示 + 禁用 Deploy 按钮
**Why**: YAML 解析失败不应该静默忽略,需要让用户感知输入错误。客户端提前验证比后端日志更友好
**How to apply**: 任何格式转换YAML/JSON/Marshal失败都要有明确反馈日志/返回错误),不能静默跳过
## Lesson 20: Next.js `next-server` 进程持有旧 Build 缓存 (2026-04-22)
**现象**: `.next` 目录 rebuild 后,`ocdp-frontend` 容器(运行 `next-server`)的 HTML 仍引用旧的 JS chunk 哈希,导致 500
**根因**: Next.js Server Component 的 RSC payload 和 HTML 是在 `next-server` 进程启动时从 `.next` 目录读取的。简单的 `docker cp` 替换 `.next` 目录后,**进程内存中的路由映射仍是旧的**。需要 `docker restart` 重启进程
**How to apply**: 更新 Next.js 容器后必须 `docker restart <container>` 而非仅 `docker cp`,否则 RSC payload 和 HTML 模板不一致
## Lesson 21: AES-GCM 解密失败静默破坏 kubeconfig 明文数据 (2026-04-22)
**现象**: browser 点击 Deploy 后端报错 "no valid credentials found for cluster cluster1",但 CA 证书能正常解析到 K8s server cert
**根因**: cluster1 的 `ca_data` 字段存储了明文 kubeconfig`apiVersion: v1\n...` 格式)。`GetByID` 总是调用 `encryptor.Decrypt()`AES-GCM 解密失败后返回乱码并丢弃。后续 `createRestConfig` 检测不到 kubeconfig 格式(乱码非 `apiVersion:`),且 cert/key 字段为空,最终走 kubeconfig 文件分支失败
**修复**: 添加 `decryptIfNeeded()` helper检测数据以 `apiVersion:``kind:` 开头则跳过解密直接返回原值。更新 `GetByID``GetByName``scanClusters` 三处调用
**Why**: 明文 kubeconfig 绕过了解密流程,因为没有任何"已加密"标记。检测格式前缀是区分加密/明文的最简方式
**How to apply**: 所有存储加密数据的 Repository在解密前必须检查数据格式。kubeconfig 内容不需要加密(已在文件系统中受保护),用明文格式存储更简单
## Lesson 22: PostgreSQL 存储 Base64 编码加密数据 vs 直接存储加密字节 (2026-04-22)
**现象**: clusters 表的 `ca_data` 等字段存储的是 `Encrypt()` 函数的 Base64 输出。但 Encrypt() 内部已经做了 Base64 编码,所以这些字段存的是**双重 Base64**crypto output 的 Base64 再次作为普通文本存储)
**分析**: `Encrypt()` 返回 `base64.StdEncoding.EncodeToString(ciphertext)`ciphertext 包含 nonce+tag+内容,已是标准 Base64 格式。这个 Base64 字符串直接存储到 text 字段即可
**How to apply**: Go 的 `crypto.Encrypt()` → Base64 字符串 → 直接存 PostgreSQL text 字段。读取时需要先从 text 列获取 Base64 字符串,再调用 `Decrypt()` 解析。这与前端发送 PEM 内容(`LS0tLS1...`)直接加密存储不同