|
| 1 | +--- |
| 2 | +phase: 57-resource-limits |
| 3 | +plan: 01 |
| 4 | +type: execute |
| 5 | +wave: 1 |
| 6 | +depends_on: [] |
| 7 | +files_modified: |
| 8 | + - internal/store/migrations/0022_host_resource_limits_nullable.sql |
| 9 | + - internal/store/repository/models.go |
| 10 | + - internal/store/repository/queries.go |
| 11 | +autonomous: true |
| 12 | +requirements: |
| 13 | + - RES-01 |
| 14 | + |
| 15 | +must_haves: |
| 16 | + truths: |
| 17 | + - "管理员可以选择'无限制'作为任何资源类型的值" |
| 18 | + - "数据库迁移后现有主机的资源限制值保持不变" |
| 19 | + - "代码编译通过,现有测试继续通过" |
| 20 | + artifacts: |
| 21 | + - path: "internal/store/migrations/0022_host_resource_limits_nullable.sql" |
| 22 | + provides: "把 memory_limit_mb / cpu_limit / disk_limit_gb 三列改为 nullable" |
| 23 | + contains: "ALTER COLUMN memory_limit_mb DROP NOT NULL" |
| 24 | + - path: "internal/store/repository/models.go" |
| 25 | + provides: "Host struct 和 UpsertHostParams 的资源字段改用 *int / *float64" |
| 26 | + min_lines: 5 |
| 27 | + - path: "internal/store/repository/queries.go" |
| 28 | + provides: "移除 UpsertHost 中的 defaultIfZero 逻辑,新增 UpdateHostResources 方法" |
| 29 | + exports: ["UpdateHostResources"] |
| 30 | + key_links: |
| 31 | + - from: "models.go Host.MemoryLimitMB *int" |
| 32 | + to: "queries.go 所有 Scan(&item.MemoryLimitMB)" |
| 33 | + via: "pgx 自动将 NULL 扫描为 nil,非 NULL 扫描为 *int" |
| 34 | + pattern: "&item\\.MemoryLimitMB" |
| 35 | + - from: "queries.go UpsertHost" |
| 36 | + to: "models.go UpsertHostParams.MemoryLimitMB *int" |
| 37 | + via: "参数直接透传给 SQL $9/$10/$11,不再做 defaultIfZero 兜底" |
| 38 | + pattern: "params\\.MemoryLimitMB" |
| 39 | +--- |
| 40 | + |
| 41 | +<objective> |
| 42 | +数据库迁移 + Go 数据模型改造:将 hosts 表的资源限制列改为 nullable,同步更新 Go 类型(int -> *int, float64 -> *float64),移除 Repository 层的 defaultIfZero 默认值兜底逻辑,新增 UpdateHostResources 方法供 PATCH API 使用。 |
| 43 | + |
| 44 | +Purpose: 为"无限制"语义提供数据层基础。NULL = 无限制,正值 = 具体限制。默认值注入从 DB 层收拢到 API 层。 |
| 45 | +Output: 迁移文件 + 更新后的 models.go + 更新后的 queries.go |
| 46 | +</objective> |
| 47 | + |
| 48 | +<execution_context> |
| 49 | +@.claude/get-shit-done/workflows/execute-plan.md |
| 50 | +@.claude/get-shit-done/templates/summary.md |
| 51 | +</execution_context> |
| 52 | + |
| 53 | +<context> |
| 54 | +@.planning/PROJECT.md |
| 55 | +@.planning/ROADMAP.md |
| 56 | +@.planning/STATE.md |
| 57 | +@.planning/phases/57-resource-limits/57-CONTEXT.md |
| 58 | +@.planning/phases/57-resource-limits/57-RESEARCH.md |
| 59 | + |
| 60 | +<interfaces> |
| 61 | +<!-- 执行器需要用到的现有类型和契约。从代码库提取,执行器无需重新探索。 --> |
| 62 | + |
| 63 | +来自 internal/store/repository/models.go L34-50(当前 Host struct): |
| 64 | +```go |
| 65 | +type Host struct { |
| 66 | + // ... |
| 67 | + MemoryLimitMB int `json:"memory_limit_mb"` |
| 68 | + CPULimit float64 `json:"cpu_limit"` |
| 69 | + DiskLimitGB int `json:"disk_limit_gb"` |
| 70 | + // ... |
| 71 | +} |
| 72 | +``` |
| 73 | + |
| 74 | +来自 internal/store/repository/models.go L268-281(当前 UpsertHostParams): |
| 75 | +```go |
| 76 | +type UpsertHostParams struct { |
| 77 | + // ... |
| 78 | + MemoryLimitMB int |
| 79 | + CPULimit float64 |
| 80 | + DiskLimitGB int |
| 81 | + // ... |
| 82 | +} |
| 83 | +``` |
| 84 | + |
| 85 | +来自 internal/store/repository/queries.go L359-372(当前 UpsertHost 默认值兜底,将被移除): |
| 86 | +```go |
| 87 | +memoryLimitMB := params.MemoryLimitMB |
| 88 | +if memoryLimitMB == 0 { |
| 89 | + memoryLimitMB = 4096 |
| 90 | +} |
| 91 | +cpuLimit := params.CPULimit |
| 92 | +if cpuLimit == 0 { |
| 93 | + cpuLimit = 2.0 |
| 94 | +} |
| 95 | +diskLimitGB := params.DiskLimitGB |
| 96 | +if diskLimitGB == 0 { |
| 97 | + diskLimitGB = 20 |
| 98 | +} |
| 99 | +``` |
| 100 | +</interfaces> |
| 101 | +</context> |
| 102 | + |
| 103 | +<tasks> |
| 104 | + |
| 105 | +<task type="auto"> |
| 106 | + <name>Task 1: 创建数据库迁移 0022</name> |
| 107 | + <files>internal/store/migrations/0022_host_resource_limits_nullable.sql</files> |
| 108 | + <read_first> |
| 109 | + internal/store/migrations/0006_host_resource_limits.sql |
| 110 | + internal/store/migrations/0021_remove_ipv6_from_lan_preset.sql |
| 111 | + </read_first> |
| 112 | + <action> |
| 113 | +创建新文件 `internal/store/migrations/0022_host_resource_limits_nullable.sql`,写入以下 SQL(参考 RESEARCH.md "目标" 代码块): |
| 114 | + |
| 115 | +```sql |
| 116 | +-- 将 hosts 表的资源限制列改为 nullable。 |
| 117 | +-- NULL = 无限制(Docker 层面不传资源参数) |
| 118 | +-- 正值 = 具体限制 |
| 119 | +-- 现有数据保留不变(已有主机的 4096MB / 2.0 CPU / 20GB 是显式限制) |
| 120 | +-- 默认值逻辑移至 API 层(见 Phase 57 D-01 / D-02) |
| 121 | +ALTER TABLE hosts ALTER COLUMN memory_limit_mb DROP NOT NULL; |
| 122 | +ALTER TABLE hosts ALTER COLUMN cpu_limit DROP NOT NULL; |
| 123 | +ALTER TABLE hosts ALTER COLUMN disk_limit_gb DROP NOT NULL; |
| 124 | +ALTER TABLE hosts ALTER COLUMN memory_limit_mb DROP DEFAULT; |
| 125 | +ALTER TABLE hosts ALTER COLUMN cpu_limit DROP DEFAULT; |
| 126 | +ALTER TABLE hosts ALTER COLUMN disk_limit_gb DROP DEFAULT; |
| 127 | +``` |
| 128 | + |
| 129 | +注意: |
| 130 | +- `DROP NOT NULL` 和 `DROP DEFAULT` 是两条独立的 ALTER 语句,不要合并。 |
| 131 | +- 不需要 `IF EXISTS` 检查——这是一个向前迁移,列已经在 0006 迁移中创建。 |
| 132 | +- 不需要回滚逻辑——项目没有使用 `golang-migrate` 的 down 迁移模式。 |
| 133 | + </action> |
| 134 | + <verify> |
| 135 | + <automated>grep -c 'ALTER COLUMN.*DROP NOT NULL' internal/store/migrations/0022_host_resource_limits_nullable.sql | grep -q '3'</automated> |
| 136 | + </verify> |
| 137 | + <acceptance_criteria> |
| 138 | + - 文件 `internal/store/migrations/0022_host_resource_limits_nullable.sql` 存在 |
| 139 | + - 包含 `ALTER TABLE hosts ALTER COLUMN memory_limit_mb DROP NOT NULL;` |
| 140 | + - 包含 `ALTER TABLE hosts ALTER COLUMN cpu_limit DROP NOT NULL;` |
| 141 | + - 包含 `ALTER TABLE hosts ALTER COLUMN disk_limit_gb DROP NOT NULL;` |
| 142 | + - 包含三个对应的 `DROP DEFAULT` 语句 |
| 143 | + - 文件名遵循 `NNNN_description.sql` 约定(NNNN=0022,接续 0021) |
| 144 | + </acceptance_criteria> |
| 145 | + <done>迁移文件已创建,包含 6 条 ALTER 语句:3 条 DROP NOT NULL + 3 条 DROP DEFAULT</done> |
| 146 | +</task> |
| 147 | + |
| 148 | +<task type="auto" tdd="true"> |
| 149 | + <name>Task 2: Go 数据模型类型变更(models.go + UpsertHost 默认移除)</name> |
| 150 | + <files>internal/store/repository/models.go</files> |
| 151 | + <read_first> |
| 152 | + internal/store/repository/models.go |
| 153 | + internal/store/repository/queries.go |
| 154 | + </read_first> |
| 155 | + <behavior> |
| 156 | + - Test 1: pgx 扫描 NULL 到 *int 类型时,*int 应为 nil(验证 pgx 对指针类型的 NULL 处理) |
| 157 | + - Test 2: pgx 扫描非 NULL 值(如 4096)到 *int 类型时,**int 应解引用后等于 4096 |
| 158 | + - Test 3: UpsertHost 使用 *int=NULL 作为参数时,SQL 写入 NULL 到对应列 |
| 159 | + </behavior> |
| 160 | + <action> |
| 161 | +修改 `internal/store/repository/models.go` 中的两个结构体: |
| 162 | + |
| 163 | +**1. Host struct(约 L44-46)**——将三个资源字段从非指针改为指针: |
| 164 | +```go |
| 165 | +// 变更前: |
| 166 | +MemoryLimitMB int `json:"memory_limit_mb"` |
| 167 | +CPULimit float64 `json:"cpu_limit"` |
| 168 | +DiskLimitGB int `json:"disk_limit_gb"` |
| 169 | + |
| 170 | +// 变更后: |
| 171 | +MemoryLimitMB *int `json:"memory_limit_mb"` |
| 172 | +CPULimit *float64 `json:"cpu_limit"` |
| 173 | +DiskLimitGB *int `json:"disk_limit_gb"` |
| 174 | +``` |
| 175 | + |
| 176 | +**2. UpsertHostParams struct(约 L277-279)**——同样改为指针: |
| 177 | +```go |
| 178 | +// 变更前: |
| 179 | +MemoryLimitMB int |
| 180 | +CPULimit float64 |
| 181 | +DiskLimitGB int |
| 182 | + |
| 183 | +// 变更后: |
| 184 | +MemoryLimitMB *int |
| 185 | +CPULimit *float64 |
| 186 | +DiskLimitGB *int |
| 187 | +``` |
| 188 | + |
| 189 | +**3. 移除 queries.go UpsertHost 中的 defaultIfZero 逻辑(L361-372)**——删除以下 12 行: |
| 190 | +```go |
| 191 | +// 删除以下整段(L361-372): |
| 192 | +memoryLimitMB := params.MemoryLimitMB |
| 193 | +if memoryLimitMB == 0 { |
| 194 | + memoryLimitMB = 4096 |
| 195 | +} |
| 196 | +cpuLimit := params.CPULimit |
| 197 | +if cpuLimit == 0 { |
| 198 | + cpuLimit = 2.0 |
| 199 | +} |
| 200 | +diskLimitGB := params.DiskLimitGB |
| 201 | +if diskLimitGB == 0 { |
| 202 | + diskLimitGB = 20 |
| 203 | +} |
| 204 | +``` |
| 205 | + |
| 206 | +然后将 UpsertHost 中 `INSERT ... VALUES` 的参数从 `memoryLimitMB, cpuLimit, diskLimitGB`(已移除的局部变量)改为直接使用 `params.MemoryLimitMB, params.CPULimit, params.DiskLimitGB`(现在是 *int/*float64,pgx 原生支持指针参数:nil → SQL NULL,非 nil → 解引用值)。 |
| 207 | + |
| 208 | +注意:所有 13 处 Scan 调用点(`&item.MemoryLimitMB, &item.CPULimit, &item.DiskLimitGB`)**不需要修改代码**——当 Host 字段类型变为 `*int` 时,`&item.MemoryLimitMB` 自动变为 `**int`,pgx 会将 NULL 扫描为 `nil`(*int 的零值),非 NULL 扫描为指向堆上 int 值的指针。 |
| 209 | + </action> |
| 210 | + <verify> |
| 211 | + <automated>cd internal/store/repository && go build ./...</automated> |
| 212 | + </verify> |
| 213 | + <acceptance_criteria> |
| 214 | + - models.go 中 `Host.MemoryLimitMB` 类型为 `*int`(grep: `MemoryLimitMB\s+\*int`) |
| 215 | + - models.go 中 `Host.CPULimit` 类型为 `*float64`(grep: `CPULimit\s+\*float64`) |
| 216 | + - models.go 中 `Host.DiskLimitGB` 类型为 `*int`(grep: `DiskLimitGB\s+\*int`) |
| 217 | + - models.go 中 `UpsertHostParams.MemoryLimitMB` 类型为 `*int` |
| 218 | + - models.go 中 `UpsertHostParams.CPULimit` 类型为 `*float64` |
| 219 | + - models.go 中 `UpsertHostParams.DiskLimitGB` 类型为 `*int` |
| 220 | + - queries.go UpsertHost 方法中不再包含 `if memoryLimitMB == 0 { memoryLimitMB = 4096 }` 或类似的 defaultIfZero 逻辑 |
| 221 | + - `go build ./internal/store/repository/...` 编译成功 |
| 222 | + </acceptance_criteria> |
| 223 | + <done>Host 和 UpsertHostParams 的资源字段已改为指针类型,UpsertHost 中的 defaultIfZero 兜底逻辑已移除,repository 包编译通过</done> |
| 224 | +</task> |
| 225 | + |
| 226 | +<task type="auto"> |
| 227 | + <name>Task 3: 新增 Repository.UpdateHostResources 方法</name> |
| 228 | + <files>internal/store/repository/queries.go</files> |
| 229 | + <read_first> |
| 230 | + internal/store/repository/queries.go |
| 231 | + internal/store/repository/models.go |
| 232 | + </read_first> |
| 233 | + <action> |
| 234 | +在 `internal/store/repository/queries.go` 中,紧接 `UpdateHostMounts` 方法(约 L1542)之后,新增 `UpdateHostResources` 方法。参考 `UpdateHostMounts` 的简约模式(直接 `r.db.Exec`,不返回 Host 对象以免额外 SELECT): |
| 235 | + |
| 236 | +```go |
| 237 | +// UpdateHostResources 更新主机的资源限制(内存/CPU/磁盘)。 |
| 238 | +// 三个参数均为指针:nil 表示不更新该字段,非 nil 的 0 值表示无限制(写入 NULL)。 |
| 239 | +// 仅在 host.Status == "stopped" 时由 PATCH API 调用;Repository 层不做状态校验。 |
| 240 | +func (r *Repository) UpdateHostResources(ctx context.Context, hostID string, memoryLimitMB *int, cpuLimit *float64, diskLimitGB *int) error { |
| 241 | + _, err := r.db.Exec(ctx, ` |
| 242 | + UPDATE hosts |
| 243 | + SET memory_limit_mb = COALESCE($1, memory_limit_mb), |
| 244 | + cpu_limit = COALESCE($2, cpu_limit), |
| 245 | + disk_limit_gb = COALESCE($3, disk_limit_gb), |
| 246 | + updated_at = NOW() |
| 247 | + WHERE id = $4 |
| 248 | + `, memoryLimitMB, cpuLimit, diskLimitGB, hostID) |
| 249 | + if err != nil { |
| 250 | + return fmt.Errorf("update host resources: %w", err) |
| 251 | + } |
| 252 | + return nil |
| 253 | +} |
| 254 | +``` |
| 255 | + |
| 256 | +COALESCE 语义:当参数为 nil(Go 层传 nil → pgx 传 SQL NULL)时,COALESCE 回退到列当前值,即"不更新该字段"。当参数为非 nil 指针时(包括指向 0 的指针),写入该值(0 = NULL 即无限制,正数 = 限制)。 |
| 257 | + </action> |
| 258 | + <verify> |
| 259 | + <automated>grep -n 'func (r \*Repository) UpdateHostResources' internal/store/repository/queries.go</automated> |
| 260 | + </verify> |
| 261 | + <acceptance_criteria> |
| 262 | + - queries.go 中存在 `func (r *Repository) UpdateHostResources(ctx context.Context, hostID string, memoryLimitMB *int, cpuLimit *float64, diskLimitGB *int) error` 方法 |
| 263 | + - SQL 使用 `COALESCE($1, memory_limit_mb)` 模式处理三个资源列 |
| 264 | + - `go build ./internal/store/repository/...` 编译成功 |
| 265 | + </acceptance_criteria> |
| 266 | + <done>Repository.UpdateHostResources 方法已添加,使用 COALESCE 实现部分更新语义,repository 包编译通过</done> |
| 267 | +</task> |
| 268 | + |
| 269 | +</tasks> |
| 270 | + |
| 271 | +<threat_model> |
| 272 | +## Trust Boundaries |
| 273 | + |
| 274 | +| Boundary | Description | |
| 275 | +|----------|-------------| |
| 276 | +| 无 | 本计划仅涉及数据库迁移和内部 Go 类型变更,不暴露新的 API 端点,无新增信任边界 | |
| 277 | + |
| 278 | +## STRIDE Threat Register |
| 279 | + |
| 280 | +| Threat ID | Category | Component | Disposition | Mitigation Plan | |
| 281 | +|-----------|----------|-----------|-------------|-----------------| |
| 282 | +| T-57-01 | Tampering | 0022 migration | accept | 迁移仅修改列约束(NOT NULL → nullable),不修改数据。现有 4096/2.0/20 值保留。 | |
| 283 | +| T-57-02 | Denial of Service | UpsertHost params | accept | 移除 defaultIfZero 后,调用方可能传入 nil → SQL NULL。PostgreSQL 接受 NULL 无性能影响。API 层将在后续 Plan 中做范围校验。 | |
| 284 | +</threat_model> |
| 285 | + |
| 286 | +<verification> |
| 287 | +## 整体验证 |
| 288 | + |
| 289 | +```bash |
| 290 | +# 编译检查 repository 包 |
| 291 | +cd internal/store/repository && go build ./... |
| 292 | + |
| 293 | +# 运行 repository 包现有测试 |
| 294 | +go test ./internal/store/repository/... -count=1 |
| 295 | + |
| 296 | +# 确认迁移文件存在且语法正确 |
| 297 | +grep -c 'DROP NOT NULL' internal/store/migrations/0022_host_resource_limits_nullable.sql |
| 298 | +``` |
| 299 | + |
| 300 | +预期:编译成功,现有测试通过(指针类型变更不改变测试中的非 NULL 值行为),迁移文件包含 3 个 DROP NOT NULL。 |
| 301 | +</verification> |
| 302 | + |
| 303 | +<success_criteria> |
| 304 | +- [ ] 迁移文件 0022 存在,包含 3 条 DROP NOT NULL + 3 条 DROP DEFAULT |
| 305 | +- [ ] Host 和 UpsertHostParams 的资源字段类型已改为 *int / *float64 |
| 306 | +- [ ] UpsertHost 不再包含 defaultIfZero 逻辑 |
| 307 | +- [ ] UpdateHostResources 方法已添加,使用 COALESCE 部分更新 |
| 308 | +- [ ] `go build ./internal/store/repository/...` 成功 |
| 309 | +- [ ] 仓库现有测试继续通过(`go test ./internal/store/repository/...`) |
| 310 | +</success_criteria> |
| 311 | + |
| 312 | +<output> |
| 313 | +完成后创建 `.planning/phases/57-resource-limits/57-01-SUMMARY.md` |
| 314 | +</output> |
0 commit comments