Files
ocdp-go/tasks/lessons.md
Ivan087 47849042a7 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
2026-04-30 16:31:00 +08:00

147 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Lessons Learned
## Bug 1: Frontend/Bulk Field Name Mismatch (2026-04-16)
**现象**: 部署 API 返回 400 "invalid version",即使前端传了正确的 version
**根因**: 前端发送 JSON 字段 `version`,但 DTO 只有 `Tag`json: `tag`handler 读 `req.Tag` 始终为空
**修复**: 在 CreateInstanceRequest 中添加 `Version` 字段,并在 Normalize() 中将 Version 复制到 Tag
**How to apply**: 前后端接口字段名必须一致。DTO 的 json tag 应与前端发送的字段名匹配,或在 Normalize() 中做兼容映射
## Bug 2: Registry Decrypt Fails with Key Mismatch (2026-04-16)
**现象**: GET /registries 列表正常,但 GET /registries/{id} 返回 404 "failed to decrypt password"
**根因**: 旧数据用不同 ENCRYPTION_KEY 加密GetByID/GetByName 解密失败直接返回 error
**修复**: 解密失败时返回空密码而非错误(与 List 方法行为一致)
**Why**: 列表查询不触发解密(`_ = r.encryptor.Decrypt`),但单条查询需要解密。密钥不匹配不应阻断核心业务流程
**How to apply**: 涉及敏感数据解密时,对密钥不匹配的情况做 graceful fallback 而非直接报错
## Bug 3: Docker Compose Project Conflict (2026-04-16)
**现象**: 从 backend/ 目录运行 docker compose 时报错 "container name already in use"
**根因**: 容器通过不同 compose 项目启动ocdp-go vs backend但使用了相同的容器名
**修复**: 直接用 docker 命令重启旧容器docker stop/rm 后用 docker run 启动新镜像
**How to apply**: 当有多个 compose 文件管理同一网络时docker run 方式更灵活
## Bug 4: InitSchema vs Actual DB Schema Mismatch (2026-04-16)
**现象**: InitSchema() 创建的 registries 表缺少 workspace_id, owner_id, is_shared 字段
**根因**: 代码中的 InitSchema 与实际 init-db.sql 不同步
**影响**: GetByID/GetByName 查询时字段数不匹配会报错
**Fix**: 修复 GetByID/GetByName 的查询和 Scan使用实际的 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...`)直接加密存储不同