Skip to content

Commit 9d86b9a

Browse files
committed
docs(57): create phase 57 resource limits plans
3 plans, 2 waves, 4 REQ covered: - 57-01: DB migration + Go type changes (int->*int, float64->*float64) - 57-02: API tri-state + PATCH endpoint + worker --storage-opt - 57-03: Frontend ResourceLimitsSelector + create form + detail page Decisions: D-01..D-06 fully mapped to tasks.
1 parent 3f5ec57 commit 9d86b9a

4 files changed

Lines changed: 1547 additions & 3 deletions

File tree

.planning/ROADMAP.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -443,11 +443,13 @@
443443
**Goal:** 允许管理员在创建和停止主机时手动设置内存、CPU 和磁盘限制,支持"无限制"选项。数据库列改为 nullable(NULL = 无限制),API 使用指针类型区分三态(省略=默认 / 0=无限制 / 正值=限制),新增 PATCH 端点,前端提供直观的预设+自定义选择控件。
444444
**Requirements**: RES-01(无限制语义)/ RES-02(PATCH API)/ RES-03(前端控件)/ RES-04(磁盘限制执行)
445445
**Depends on:** Phase 56
446-
**Plans:** 0 plans
446+
**Plans:** 3 plans
447447

448448
Plans:
449-
- [ ] TBD (run /gsd-plan-phase 57 to break down)
449+
- [ ] 57-01-PLAN.md — 数据库迁移 + Go 数据模型类型变更(RES-01)
450+
- [ ] 57-02-PLAN.md — API 三态解析 + PATCH 端点 + Worker --storage-opt(RES-02, RES-04)
451+
- [ ] 57-03-PLAN.md — 前端资源限制选择器 + 创建表单 + 详情页编辑(RES-03)
450452

451453
---
452454

453-
*Last updated: 2026-05-16v4.0 sing-box 同容器化 milestone started (Phases 53-56, 28 REQ, 6 locked decisions D-V4-1..6). v3.6 shipped & archived (8 phase / 39 plan / 38 REQ).*
455+
*Last updated: 2026-05-29Phase 57 planned (3 plans, 2 waves, 4 REQ covered).*
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
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

Comments
 (0)