Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions docs/ZSPHER-244-api-design-tradeoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# ZSPHER-244 API 设计权衡:扩展现有 API vs 新增 API

> 关联工单:[ZSPHER-244](http://jira.zstack.io/browse/ZSPHER-244) — 更改数据存储支持数据盘指定不同目标
> 关联前置:[ZSV-12280](http://jira.zstack.io/browse/ZSV-12280) — SharedBlock 多数据盘热迁移
Comment on lines +3 to +4
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

将 Jira 链接统一为 HTTPS。

当前文档引用使用了 http://,建议改为 https:// 以避免跳转或安全告警。

建议修改
-> 关联工单:[ZSPHER-244](http://jira.zstack.io/browse/ZSPHER-244) — 更改数据存储支持数据盘指定不同目标
-> 关联前置:[ZSV-12280](http://jira.zstack.io/browse/ZSV-12280) — SharedBlock 多数据盘热迁移
+> 关联工单:[ZSPHER-244](https://jira.zstack.io/browse/ZSPHER-244) — 更改数据存储支持数据盘指定不同目标
+> 关联前置:[ZSV-12280](https://jira.zstack.io/browse/ZSV-12280) — SharedBlock 多数据盘热迁移
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
> 关联工单:[ZSPHER-244](http://jira.zstack.io/browse/ZSPHER-244) — 更改数据存储支持数据盘指定不同目标
> 关联前置:[ZSV-12280](http://jira.zstack.io/browse/ZSV-12280) — SharedBlock 多数据盘热迁移
> 关联工单:[ZSPHER-244](https://jira.zstack.io/browse/ZSPHER-244) — 更改数据存储支持数据盘指定不同目标
> 关联前置:[ZSV-12280](https://jira.zstack.io/browse/ZSV-12280) — SharedBlock 多数据盘热迁移
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/ZSPHER-244-api-design-tradeoff.md` around lines 3 - 4, Update the two
Jira links referencing ZSPHER-244 and ZSV-12280 to use HTTPS instead of HTTP;
locate the strings "http://jira.zstack.io/browse/ZSPHER-244" and
"http://jira.zstack.io/browse/ZSV-12280" in the document and replace their
schemes with "https://".

> 状态:设计阶段
> 决策建议:方案 B(新增独立 API)

---

## 1. 背景

`APIPrimaryStorageMigrateVolumeMsg` 当前承担的语义已经叠了两层:

| 演进阶段 | 字段 | 适用场景 |
|---------|------|---------|
| 最早 | `volumeUuid`(path) + `dstPrimaryStorageUuid` | 单盘冷迁移(VM Stopped 或未挂载) |
| ZSV-12280(已落) | `volumeUuids: List<String>` + `dstPrimaryStorageUuid` | 多盘热迁移(VM Running/Paused,**统一目标 PS**) |
| ZSPHER-244(待做) | 每盘独立目标:`List<{volumeUuid, dstPsUuid}>` | 多盘**冷迁移**(VM Stopped,**每盘不同目标**) |

三种语义共用一个 API 已经出现"互斥字段"问题(`volumeUuid` 与 `volumeUuids` 必须二选一/包含关系)。ZSPHER-244 是否继续叠加,需要明确决策。

---

## 2. 方案 A:继续给 `APIPrimaryStorageMigrateVolumeMsg` 加参数

新增字段 `volumeMigrationSpecs: List<VolumeMigrationSpec{volumeUuid, dstPrimaryStorageUuid}>`。

### 优点
- 前端入口统一(一个 API 处理 volume 迁移所有场景)
- SDK 兼容:旧字段保留
- LongJob 调度复用现有 `PrimaryStorageMigrateVolumeJob`

### 缺点(多数已经显现)
1. **互斥字段爆炸**:`volumeUuid` / `volumeUuids` / `volumeMigrationSpecs` / `dstPrimaryStorageUuid` 之间需要 5+ 条互斥规则,Interceptor 校验逻辑将剧增。
2. **语义模糊**:同一 API 既是热迁移又是冷迁移,既支持单目标又支持多目标——文档和 SDK 注释难写。
3. **路径占位符 `volumeUuid` 越来越尴尬**:当请求是「按盘指定目标」时,path 上的 volumeUuid 是哪一块?需要继续打补丁说"必须是 specs 中的某一块"。
4. **审计/事件 inventory 形状不一致**:单目标返回 `VolumeInventory`,多目标返回 `List<VolumeInventory>`。老调用方期望单个,新调用方期望列表,已经在用 `inventories.get(0)` 凑活。
5. **MN 升级风险**:当前 `PrimaryStorageMigrateVolumeJob.start` 用 `JSONObjectUtil.toObject` 反序列化 jobData,加字段虽兼容但分支判断越来越复杂。

---

## 3. 方案 B:新增独立 API `APIPrimaryStorageMigrateDataVolumesMsg`

专门承载 ZSPHER-244 语义:

```java
@Action(category = VolumeConstant.ACTIONS)
public class APIPrimaryStorageMigrateDataVolumesMsg extends APIMessage {
@APIParam(resourceType = VmInstanceVO.class)
private String vmInstanceUuid;

@APIParam(nonempty = true)
private List<VolumeMigrationSpec> volumeMigrationSpecs;
// VolumeMigrationSpec: volumeUuid + dstPrimaryStorageUuid (+ optional withSnapshots)
}
```

### 优点
1. **语义干净**:一个 API 一种语义,文档/SDK/审计天然清晰。
2. **Interceptor 简单**:只校验自己的字段,与老 API 完全解耦。
3. **入口分流**:冷迁移多目标走新 Msg → 新 Job(`PrimaryStorageMigrateDataVolumesJob`);老 API 不动,回归"经典单盘迁移"角色。
4. **Inventory 形状自然**:新事件直接返回 `List<VolumeInventory>`,不用兼容老形状。
5. **演进空间**:未来 ZSPHER-244 的衍生需求(按盘选 withSnapshots、按盘选 strategy 等)只在新 Msg 上扩展,不污染老路径。
6. **可独立灰度**:新 API 出问题不影响存量。

### 缺点
1. 前端要多一个 API 调用入口(但 UI 本来就是新的高级选项面板,天然走新接口)。
2. SDK 需要重新生成(项目里本来就是必经流程)。
3. 多了一个 LongJob 类(但本来就需要新逻辑,写在哪里都一样)。

---

## 4. 同类先例参考

ZStack 自身的 API 演进经验:

- `APIMigrateVmMsg`(冷)/ `APILiveMigrateVmMsg`(热)—— **是分开的两个 API**,没有共用 `migrateVm` 加 `live: boolean`。
- `APIChangeVmHaPolicyMsg` / `APISetVmInstanceDefaultCdRomMsg` 这类语义独立的操作,从不强行复用旧 API。
- 当年 `volumeUuids` 加到 `APIPrimaryStorageMigrateVolumeMsg` 已经是历史包袱(即 ZSV-12280 当前进行中的工作),现在如果再叠一层 multi-target,会让这个 API 彻底变成"什么都能干的 god API"。

---

## 5. 推荐:方案 B(新增独立 API)

### 关键理由
1. ZSPHER-244 是 **冷迁移 + 每盘独立目标**,与 ZSV-12280 的 **热迁移 + 统一目标** 实际是两条完全独立的执行链路:
- 冷迁移:`MigrateVolumeOnPrimaryStorageMsg` fan-out(每盘可走不同目标 PS)
- 热迁移:KVM live storage migration workflow(per-volume LUN switch)

共用 API 只是表面省事,后端早晚要按字段分流。

2. **Interceptor 复杂度上限**:现在 `validateLiveMigrateMultiDataVolumes` 已经接近 80 行,再叠一种语义会到 150+ 行,可维护性崩盘。

3. **老 API 已经背了一个互斥规则补丁**(path `volumeUuid` 必须 ∈ body `volumeUuids`),再加规则会让前端联调和文档维护痛苦。

### 建议落地形态

- 新增 `APIPrimaryStorageMigrateDataVolumesMsg` + `APIPrimaryStorageMigrateDataVolumesEvent`
- 新增 `PrimaryStorageMigrateDataVolumesJob`(LongJob),内部串行/并行调用现有 `MigrateVolumeOnPrimaryStorageMsg`(每盘一个目标 PS)
- Interceptor 单独写 `validate(APIPrimaryStorageMigrateDataVolumesMsg)`,与 multi-volume 热迁移校验完全分离
- 老 `APIPrimaryStorageMigrateVolumeMsg.volumeUuids` 路径继续仅服务 ZSV-12280 热迁移场景,不改动

### 边界情况
- 如果 PD 要求"按盘选目标"也能用于**热迁移**,再单独评估是否要在新 API 上加 `live: boolean`,或者再开一个 `APILiveMigrateDataVolumesToDifferentTargets`。
- 但 ZSPHER-244 原文明确写「SAN ↔ SAN **冷迁移**」,目前不必预留。

---

## 6. 决策待办

- [ ] 与 PD 确认 ZSPHER-244 是否仅冷迁移场景
- [ ] 与前端确认是否接受新 API(UI 本身就是新面板,预计无阻力)
- [ ] 与架构组评审字段命名 `volumeMigrationSpecs` vs `volumeTargets`
- [ ] 确认审计 inventory 形状(List 返回是否需要追加 `failedVolumes` 字段)
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package org.zstack.sdk;

import java.util.HashMap;
import java.util.Map;
import org.zstack.sdk.*;

public class PrimaryStorageMigrateDataVolumesAction extends AbstractAction {

private static final HashMap<String, Parameter> parameterMap = new HashMap<>();

private static final HashMap<String, Parameter> nonAPIParameterMap = new HashMap<>();

public static class Result {
public ErrorCode error;
public org.zstack.sdk.PrimaryStorageMigrateDataVolumesResult value;

public Result throwExceptionIfError() {
if (error != null) {
throw new ApiException(
String.format("error[code: %s, description: %s, details: %s]", error.code, error.description, error.details)
);
}

return this;
}
}

@Param(required = false, nonempty = false, nullElements = false, emptyString = true, noTrim = false)
public java.lang.String vmInstanceUuid;

@Param(required = true, nonempty = true, nullElements = false, emptyString = true, noTrim = false)
public java.util.List volumeMigrationSpecs;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

使用泛型类型参数以增强类型安全

volumeMigrationSpecs 声明为原始 List 类型,缺失泛型参数会导致编译器警告并丧失编译期类型检查。根据 PR 上下文,该字段应为 List<VolumeMigrationSpec>,明确元素类型可避免运行时类型转换错误。

🔧 建议修复
     `@Param`(required = true, nonempty = true, nullElements = false, emptyString = true, noTrim = false)
-    public java.util.List volumeMigrationSpecs;
+    public java.util.List<VolumeMigrationSpec> volumeMigrationSpecs;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/src/main/java/org/zstack/sdk/PrimaryStorageMigrateDataVolumesAction.java`
at line 32, The field volumeMigrationSpecs in
PrimaryStorageMigrateDataVolumesAction is declared as a raw List; change its
declaration to use a generic type List<VolumeMigrationSpec> to restore
compile-time type safety and eliminate raw-type warnings—update any places that
construct or iterate this list (constructors, setters/getters, or JSON
serialization usages) to use the VolumeMigrationSpec type as well so all usages
compile cleanly.


@Param(required = false)
public java.util.List systemTags;

@Param(required = false)
public java.util.List userTags;

@Param(required = false)
public String sessionId;

@Param(required = false)
public String accessKeyId;

@Param(required = false)
public String accessKeySecret;

@Param(required = false)
public String requestIp;

@NonAPIParam
public long timeout = -1;

@NonAPIParam
public long pollingInterval = -1;


private Result makeResult(ApiResult res) {
Result ret = new Result();
if (res.error != null) {
ret.error = res.error;
return ret;
}

org.zstack.sdk.PrimaryStorageMigrateDataVolumesResult value = res.getResult(org.zstack.sdk.PrimaryStorageMigrateDataVolumesResult.class);
ret.value = value == null ? new org.zstack.sdk.PrimaryStorageMigrateDataVolumesResult() : value;

return ret;
}

public Result call() {
ApiResult res = ZSClient.call(this);
return makeResult(res);
}

public void call(final Completion<Result> completion) {
ZSClient.call(this, new InternalCompletion() {
@Override
public void complete(ApiResult res) {
completion.complete(makeResult(res));
}
});
}

protected Map<String, Parameter> getParameterMap() {
return parameterMap;
}

protected Map<String, Parameter> getNonAPIParameterMap() {
return nonAPIParameterMap;
}

protected RestInfo getRestInfo() {
RestInfo info = new RestInfo();
info.httpMethod = "PUT";
info.path = "/primary-storage/data-volumes/actions";
info.needSession = true;
info.needPoll = true;
info.parameterName = "primaryStorageMigrateDataVolumes";
return info;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package org.zstack.sdk;



public class PrimaryStorageMigrateDataVolumesResult {
public java.util.List inventories;
public void setInventories(java.util.List inventories) {
this.inventories = inventories;
}
public java.util.List getInventories() {
return this.inventories;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@ public Result throwExceptionIfError() {
}
}

@Param(required = true, nonempty = false, nullElements = false, emptyString = true, noTrim = false)
@Param(required = false, nonempty = false, nullElements = false, emptyString = true, noTrim = false)
public java.lang.String volumeUuid;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@Param(required = false)
public java.util.List<String> volumeUuids;

Comment on lines +28 to +33
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

缺少 volumeUuid / volumeUuids 的入参互斥或至少其一约束

当前两个字段都为可选,且没有本地校验,允许“两个都不传”或“两个都传”的无效状态进入请求层。建议增加 one-of 校验(至少一个且最好只允许一个),并对 volumeUuids 增加 nonempty/nullElements 约束,避免空列表和空元素下发。

可参考的最小校验补丁
 public Result call() {
+    validateVolumeParams();
     ApiResult res = ZSClient.call(this);
     return makeResult(res);
 }

 public void call(final Completion<Result> completion) {
+    validateVolumeParams();
     ZSClient.call(this, new InternalCompletion() {
         `@Override`
         public void complete(ApiResult res) {
             completion.complete(makeResult(res));
         }
     });
 }
+
+private void validateVolumeParams() {
+    boolean hasSingle = volumeUuid != null && !volumeUuid.isEmpty();
+    boolean hasBatch = volumeUuids != null && !volumeUuids.isEmpty();
+    if (!hasSingle && !hasBatch) {
+        throw new ApiException("either volumeUuid or volumeUuids must be provided");
+    }
+    if (hasSingle && hasBatch) {
+        throw new ApiException("volumeUuid and volumeUuids cannot be provided together");
+    }
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/src/main/java/org/zstack/sdk/PrimaryStorageMigrateVolumeAction.java`
around lines 28 - 33, In PrimaryStorageMigrateVolumeAction, enforce a one-of
constraint between volumeUuid and volumeUuids so requests cannot have both empty
or both set: add validation that at least one of the fields is provided and
ideally only one is set; also tighten volumeUuids' Param annotation by adding
nonempty = true and nullElements = false to reject empty lists and null entries;
implement this check in the action's validate()/checkParameters() path (or
equivalent pre-request validation) and return a clear validation error when the
one-of rule or list constraints are violated.

@Param(required = true, nonempty = false, nullElements = false, emptyString = true, noTrim = false)
public java.lang.String dstPrimaryStorageUuid;

Expand Down
21 changes: 21 additions & 0 deletions sdk/src/main/java/org/zstack/sdk/VolumeMigrationSpec.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.zstack.sdk;

public class VolumeMigrationSpec {
public java.lang.String volumeUuid;
public java.lang.String dstPrimaryStorageUuid;

public void setVolumeUuid(java.lang.String volumeUuid) {
this.volumeUuid = volumeUuid;
}
public java.lang.String getVolumeUuid() {
return this.volumeUuid;
}

public void setDstPrimaryStorageUuid(java.lang.String dstPrimaryStorageUuid) {
this.dstPrimaryStorageUuid = dstPrimaryStorageUuid;
}
public java.lang.String getDstPrimaryStorageUuid() {
return this.dstPrimaryStorageUuid;
}

}
27 changes: 27 additions & 0 deletions testlib/src/main/java/org/zstack/testlib/ApiHelper.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -20510,6 +20510,33 @@ abstract class ApiHelper {
}


def primaryStorageMigrateDataVolumes(@DelegatesTo(strategy = Closure.OWNER_FIRST, value = org.zstack.sdk.PrimaryStorageMigrateDataVolumesAction.class) Closure c) {
def a = new org.zstack.sdk.PrimaryStorageMigrateDataVolumesAction()
a.sessionId = Test.currentEnvSpec?.session?.uuid
c.resolveStrategy = Closure.OWNER_FIRST
c.delegate = a
c()


if (System.getProperty("apipath") != null) {
if (a.apiId == null) {
a.apiId = Platform.uuid
}

def tracker = new ApiPathTracker(a.apiId)
def out = errorOut(a.call())
def path = tracker.getApiPath()
if (!path.isEmpty()) {
Test.apiPaths[a.class.name] = path.join(" --->\n")
}

return out
} else {
return errorOut(a.call())
}
}


def primaryStorageMigrateVm(@DelegatesTo(strategy = Closure.OWNER_FIRST, value = org.zstack.sdk.PrimaryStorageMigrateVmAction.class) Closure c) {
def a = new org.zstack.sdk.PrimaryStorageMigrateVmAction()
a.sessionId = Test.currentEnvSpec?.session?.uuid
Expand Down