---
phase: 03-cluster
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/config/mod.rs
  - src/config/cluster.rs
  - src/cluster/mod.rs
  - src/cluster/templates/mod.rs
  - src/cluster/templates/dm_ini.rs
  - src/cluster/templates/dmmal_ini.rs
  - src/cluster/templates/dmarch_ini.rs
  - src/cluster/templates/dmwatcher_ini.rs
  - tests/fixtures/cluster_valid.toml
  - tests/fixtures/cluster_invalid_no_primary.toml
autonomous: true
requirements:
  - CLUS-01
must_haves:
  truths:
    - "用户编写的集群 TOML 可被 ClusterConfig 反序列化，含主备两节点 + SSH 凭据 + 安装包路径"
    - "validate_cluster_config 拒绝缺少 primary 节点、缺少 SSH 凭据、端口冲突、oguid 越界等非法配置"
    - "控制机可生成 dm.ini 追加片段、dmmal.ini、dmarch.ini、dmwatcher.ini 四个配置文件字符串"
    - "dmmal.ini 主备节点内容完全相同（同一字节流），dmarch.ini ARCH_DEST 主备方向相反"
    - "dmwatcher.ini 主备节点 INST_OGUID 相同，仅 INST_INI 路径不同"
  artifacts:
    - path: "src/config/cluster.rs"
      provides: "ClusterConfig / NodeConfig / NodeRole / SshCredentials 结构体 + load_cluster_config + validate_cluster_config"
      contains: "pub struct ClusterConfig"
    - path: "src/cluster/mod.rs"
      provides: "cluster 模块根 + run 入口函数占位 + templates 子模块导出"
      contains: "pub mod templates"
    - path: "src/cluster/templates/mod.rs"
      provides: "模板函数集中导出"
      exports: ["generate_dm_ini_cluster_suffix", "generate_dmmal_ini", "generate_dmarch_ini", "generate_dmwatcher_ini"]
    - path: "tests/fixtures/cluster_valid.toml"
      provides: "完整集群 TOML 示例 fixture"
      contains: "[[cluster.nodes]]"
    - path: "tests/fixtures/cluster_invalid_no_primary.toml"
      provides: "无 primary 节点的非法 fixture"
      contains: "role = \"standby\""
  key_links:
    - from: "src/config/mod.rs"
      to: "src/config/cluster.rs"
      via: "pub mod cluster"
      pattern: "pub mod cluster"
    - from: "src/cluster/templates/mod.rs"
      to: "各 *_ini.rs 子文件"
      via: "pub mod + pub use re-export"
      pattern: "pub use .*generate_(dmmal|dmarch|dmwatcher|dm)_ini"
---

<objective>
建立 Phase 3 集群配置数据层 + 配置文件模板生成器，作为后续 SSH 部署编排的纯数据基础。

Purpose: 集群部署的"什么"（schema + 文件内容）必须先于"怎么传"（SSH/SFTP）落地。本计划纯计算、纯数据、可单元测试，无外部 IO 依赖，是垂直切片的最里层。

Output:
- `config/cluster.rs` 完成 TOML 反序列化 + 语义验证（CLUS-01 schema 部分）
- `cluster/templates/` 四个 INI 模板生成函数 + 测试覆盖主备一致性与 ARCH_DEST 方向（CLUS-01 配置生成部分）
- `cluster/mod.rs` 提供模块骨架（`run` 函数 stub 返回 `unimplemented!`，由 Plan 03 实现）
- `tests/fixtures/` 两份集群 TOML fixture
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-cluster/03-CONTEXT.md
@.planning/phases/03-cluster/03-RESEARCH.md
@.planning/phases/03-cluster/03-PATTERNS.md

# Phase 2 现有代码参照
@src/config/mod.rs
@src/install/silent_install.rs

<interfaces>
<!-- Phase 2 `InstallConfig` 的设计模板，Phase 3 `ClusterConfig` 与之并列。 -->
<!-- 从 src/config/mod.rs 提炼的完整模式（serde default + impl Default + load_and_validate 三步链） -->

From src/config/mod.rs:
- `pub struct InstallConfig { #[serde(default = "default_xxx")] pub field: T, ... }`
- 每个有默认值的字段单独 `fn default_field() -> T`
- `impl Default for InstallConfig { fn default() -> Self { Self { field: default_field(), ... } } }`
- `pub fn load_and_validate(path: &Path) -> anyhow::Result<InstallConfig>` 三步链：
  1. `std::fs::read_to_string(path).with_context(|| format!("无法读取配置文件: {}", path.display()))?`
  2. `toml::from_str::<InstallConfig>(&content).with_context(|| "配置文件解析失败")?`
  3. `validate_install_config(&cfg)?`
- `pub fn validate_install_config(cfg: &InstallConfig) -> anyhow::Result<()>` — 每条规则 `bail!("配置验证失败: 字段 无效: {值}；有效范围 ...")`

From src/install/silent_install.rs (Phase 2 模板生成模式):
- `format!(r#"<?xml version="1.0"?>...<INSTALL_PATH>{install_path}</INSTALL_PATH>..."#)` 字符串模板
- 测试断言用 `content.contains("...期望子串...")` 验证模板输出
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: 创建 ClusterConfig schema 与 fixtures</name>
  <files>
    src/config/mod.rs,
    src/config/cluster.rs,
    tests/fixtures/cluster_valid.toml,
    tests/fixtures/cluster_invalid_no_primary.toml
  </files>
  <read_first>
    src/config/mod.rs,
    .planning/phases/03-cluster/03-CONTEXT.md,
    .planning/phases/03-cluster/03-RESEARCH.md,
    .planning/phases/03-cluster/03-PATTERNS.md
  </read_first>
  <behavior>
    - Test 1 (反序列化): `cluster_valid.toml` 经 `load_cluster_config` 返回 `Ok(ClusterConfig)`，含 2 个节点：`nodes[0].role == NodeRole::Primary`、`nodes[1].role == NodeRole::Standby`、`installer_package == PathBuf::from("/tmp/dm8_setup.iso")`、`oguid == 453331`
    - Test 2 (主备实例名不同): `nodes[0].instance_name == "DMSVR01"`、`nodes[1].instance_name == "DMSVR02"`（Pitfall 2 防范，主备 instance_name 必须不同）
    - Test 3 (SSH 凭据): `nodes[0].ssh.user == "root"`、`nodes[0].ssh.identity_file == Some(PathBuf::from("~/.ssh/id_rsa"))`
    - Test 4 (默认端口): TOML 未指定 mal_port/dw_port/inst_dw_port 时，分别默认为 5237/5238/5239（与达梦官方文档 MAL_PORT/MAL_DW_PORT/MAL_INST_DW_PORT 对应）
    - Test 5 (validate 拒绝缺 primary): `cluster_invalid_no_primary.toml` 经 `load_cluster_config` 返回 Err，错误消息含 "必须恰好一个 primary 节点"
    - Test 6 (validate 拒绝端口冲突): NodeConfig 中 `mal_port == port` 时 validate 失败，消息含 "mal_port 不能等于 port"
    - Test 7 (validate 拒绝缺 SSH 凭据): `SshCredentials { identity_file: None, password: None }` validate 失败，消息含 "至少提供 identity_file 或 password"
    - Test 8 (validate 拒绝 oguid 越界): `oguid > 2147483647` 不可能（u32 max 4294967295 > i32 max），改为 oguid == 0 时合法、oguid 测试 `oguid: u32` 上限存在 → 改为：oguid 字段类型为 u32，validate 检查 `oguid <= 2_147_483_647`，超出报错
    - Test 9 (复用 InstallConfig validate 规则): 每个 NodeConfig 含 page_size/charset/extent_size 字段，复用 `validate_install_config` 风格的字段验证（page_size 必须 ∈ {4,8,16,32}）
  </behavior>
  <action>
1. 修改 `src/config/mod.rs`：
   - 在 `pub mod validate;` 同行之下新增 `pub mod cluster;`
   - 不改动任何现有 InstallConfig 字段或函数（向后兼容）

2. 新建 `src/config/cluster.rs`，导出以下 public 项：
   - `pub enum NodeRole { Primary, Standby }`，加 `#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Copy)]`、`#[serde(rename_all = "lowercase")]` 让 TOML 字符串 `"primary"` / `"standby"` 反序列化为枚举
   - `pub struct SshCredentials { pub user: String, pub identity_file: Option<PathBuf>, #[serde(skip_serializing, default)] pub password: Option<String> }`，加 `#[derive(Debug, Deserialize, Clone)]`。`password` 必须有 `#[serde(skip_serializing)]` 防日志泄漏（per RESEARCH Security Domain；threat T-03-01）
   - `pub struct NodeConfig` 字段（全部 `pub`）：`role: NodeRole`、`host: String`、`port: u16`（默认 5236）、`instance_name: String`（无默认，必填）、`install_path: String`（默认 "/opt/dmdbms"）、`data_path: String`（默认 "/opt/dmdbms/data"）、`mal_port: u16`（默认 5237）、`dw_port: u16`（默认 5238）、`inst_dw_port: u16`（默认 5239）、`page_size: u8`（默认 8）、`charset: u8`（默认 0）、`case_sensitive: bool`（默认 true）、`extent_size: u8`（默认 16）、`ssh: SshCredentials`
   - 每个有默认值的字段用 `#[serde(default = "default_xxx_node")]`，对应 `fn default_xxx_node() -> T` 私有函数（与 config/mod.rs 同模式，后缀加 `_node` 避免与 InstallConfig 默认函数冲突）
   - `pub struct ClusterConfig { pub cluster: ClusterSection }`，外层结构对应 TOML 的 `[cluster]` 表
   - `pub struct ClusterSection { pub installer_package: PathBuf, pub oguid: u32, pub nodes: Vec<NodeConfig> }`，`oguid` 有默认值 453331（per RESEARCH 默认值，主备必须相同），`nodes` 对应 `[[cluster.nodes]]` 数组
   - `pub fn load_cluster_config(path: &Path) -> anyhow::Result<ClusterConfig>` — 三步链：read_to_string → toml::from_str → validate_cluster_config，与 InstallConfig 的 load_and_validate 完全对称
   - `pub fn validate_cluster_config(cfg: &ClusterConfig) -> anyhow::Result<()>`，依次检查：
     a. `cfg.cluster.nodes.is_empty()` → bail!("配置验证失败: 集群必须至少含一个节点")
     b. `cfg.cluster.nodes.iter().filter(|n| n.role == NodeRole::Primary).count() != 1` → bail!("配置验证失败: 必须恰好一个 primary 节点")
     c. `cfg.cluster.oguid > 2_147_483_647` → bail!("配置验证失败: oguid 越界: {}；有效范围 0-2147483647", cfg.cluster.oguid)
     d. 对每个 node：`node.port == 0` → bail!("配置验证失败: node[{host}] port 无效: 0")
     e. 对每个 node：`node.mal_port == node.port` → bail!("配置验证失败: node[{host}] mal_port 不能等于 port: {}", node.port)
     f. 对每个 node：`node.ssh.identity_file.is_none() && node.ssh.password.is_none()` → bail!("配置验证失败: node[{host}] 至少提供 identity_file 或 password 之一")（per D-06）
     g. 对每个 node：page_size ∈ {4,8,16,32}、charset ∈ {0,1,2}、extent_size ∈ {16,32}（复用 InstallConfig 风格）
     h. 实例名唯一性：`nodes` 中 `instance_name` 不能重复（per RESEARCH Pitfall 2，主备 instance_name 必须不同）→ bail!("配置验证失败: instance_name 重复: {}")
   - 在 cluster.rs 末尾新增 `#[cfg(test)] mod tests { ... }`，按 <behavior> 中 9 个 Test 各写一个 `#[test] fn test_xxx()`，使用 `tempfile::NamedTempFile` + `writeln!` 模式（参照 config/mod.rs lines 156-189）

3. 创建 `tests/fixtures/cluster_valid.toml`，内容严格遵循 D-01 / D-02 schema 和 03-CONTEXT.md §Specific Ideas 示例：
   - 顶层 `[cluster]` 含 `installer_package = "/tmp/dm8_setup.iso"`、`oguid = 453331`
   - 两个 `[[cluster.nodes]]` 条目，role 分别为 `"primary"` / `"standby"`，host 分别 `"192.168.1.10"` / `"192.168.1.11"`，instance_name 分别 `"DMSVR01"` / `"DMSVR02"`，port=5236
   - 每个 node 含 `[cluster.nodes.ssh]` 子表：user="root"、identity_file="~/.ssh/id_rsa"

4. 创建 `tests/fixtures/cluster_invalid_no_primary.toml`：
   - 与 cluster_valid.toml 相同顶层 + 两个节点，但两个节点 role 都为 `"standby"`（违反 validate 规则 b）

5. 所有 cluster.rs 中的公共函数（fn 签名 + body）必须 < 40 行（CLAUDE.md universal rule）；如 validate_cluster_config 超长，拆为多个 private helper（如 `fn check_role_uniqueness`、`fn check_port_conflict`）
  </action>
  <verify>
    <automated>cargo test --lib config::cluster --no-fail-fast 2>&amp;1 | tee /tmp/p3-01-t1.log; grep -E "(test result:|FAILED)" /tmp/p3-01-t1.log; cargo test --lib config::cluster::tests 2>&amp;1 | grep -E "^test " | grep -cv "ok" | grep -q "^0$"</automated>
  </verify>
  <acceptance_criteria>
    - `cargo test --lib config::cluster::tests` exits 0
    - cluster.rs 包含 `pub struct ClusterConfig`、`pub struct NodeConfig`、`pub struct SshCredentials`、`pub enum NodeRole`、`pub fn load_cluster_config`、`pub fn validate_cluster_config` 六个 pub 项（用 `grep -E '^pub (struct|enum|fn) (ClusterConfig|NodeConfig|SshCredentials|NodeRole|load_cluster_config|validate_cluster_config)' src/config/cluster.rs | wc -l` 应得到 6）
    - `src/config/mod.rs` 含 `pub mod cluster;`（grep -q "^pub mod cluster;" src/config/mod.rs 成功）
    - `tests/fixtures/cluster_valid.toml` 文件存在且含字符串 `role = "primary"` 和 `role = "standby"` 各一次
    - `tests/fixtures/cluster_invalid_no_primary.toml` 文件存在且不含字符串 `role = "primary"`
    - SshCredentials.password 字段有 `#[serde(skip_serializing` 注解（grep `'#\[serde\(skip_serializing' src/config/cluster.rs` 至少一次匹配）
    - 至少存在以下测试函数名：`test_load_cluster_valid_two_nodes`、`test_validate_rejects_no_primary`、`test_validate_rejects_port_conflict`、`test_validate_rejects_missing_ssh_credentials`、`test_validate_rejects_duplicate_instance_name`（用 `grep -cE 'fn test_(load_cluster_valid_two_nodes|validate_rejects_no_primary|validate_rejects_port_conflict|validate_rejects_missing_ssh_credentials|validate_rejects_duplicate_instance_name)' src/config/cluster.rs` 应 ≥ 5）
  </acceptance_criteria>
  <done>
    9 个单元测试全部通过；ClusterConfig 可从 cluster_valid.toml 反序列化；validate_cluster_config 在缺 primary / 端口冲突 / 缺 SSH 凭据 / 重复 instance_name 时分别 bail! 含明确错误消息。
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: 创建 cluster 模块骨架 + 四个 INI 模板生成函数</name>
  <files>
    src/cluster/mod.rs,
    src/cluster/templates/mod.rs,
    src/cluster/templates/dm_ini.rs,
    src/cluster/templates/dmmal_ini.rs,
    src/cluster/templates/dmarch_ini.rs,
    src/cluster/templates/dmwatcher_ini.rs
  </files>
  <read_first>
    src/install/silent_install.rs,
    src/config/cluster.rs,
    .planning/phases/03-cluster/03-RESEARCH.md,
    .planning/phases/03-cluster/03-PATTERNS.md
  </read_first>
  <behavior>
    - Test 1 (dmmal 主备一致): `generate_dmmal_ini(&nodes)` 对同一 `&[NodeConfig]` 调用两次返回完全相同的 String（Pitfall 1 防范）；返回值含 `[MAL_INST1]` 和 `[MAL_INST2]` 两个段，分别含 `MAL_INST_NAME = DMSVR01` 和 `MAL_INST_NAME = DMSVR02`、`MAL_PORT = 5237`、`MAL_DW_PORT = 5238`、`MAL_INST_DW_PORT = 5239`
    - Test 2 (dmarch 主节点指向备): `generate_dmarch_ini(&primary_node, "DMSVR02")` 返回值含 `ARCH_DEST = DMSVR02`、`ARCH_TYPE = REALTIME`、`ARCH_TYPE = LOCAL`、`ARCH_DEST = /opt/dmdbms/data/arch`（基于 primary_node.data_path）
    - Test 3 (dmarch 备节点指向主): `generate_dmarch_ini(&standby_node, "DMSVR01")` 返回值含 `ARCH_DEST = DMSVR01`，与 Test 2 的 ARCH_DEST 不同（验证方向相反）
    - Test 4 (dmwatcher INST_INI 路径节点专属): `generate_dmwatcher_ini(&primary_node, 453331)` 返回值含 `INST_OGUID = 453331`、`INST_INI = /opt/dmdbms/data/DMSVR01/dm.ini`；对 standby_node 调用返回的 INST_INI 含 `DMSVR02/dm.ini`（Pitfall 3 防范）
    - Test 5 (dmwatcher OGUID 一致): 对同一 oguid=453331 调用 primary 和 standby 两次，返回字符串中 `INST_OGUID = 453331` 必须严格相等（Pitfall 5 防范）
    - Test 6 (dm.ini 集群追加片段): `generate_dm_ini_cluster_suffix(&node)` 返回字符串含 `MAL_INI = 1`、`ARCH_INI = 1`、`ALTER_MODE_STATUS = 0`、`ENABLE_OFFLINE_TS = 2` 四行（4 个字段，per RESEARCH dm.ini 新增字段）
  </behavior>
  <action>
1. 修改 `src/main.rs`（NOTE: 这一步只新增 `mod cluster;`，不动 dispatch 分支——Plan 03 负责新增 match 分支）：在现有 `mod install;` 同行之下加 `mod cluster;`

2. 新建 `src/cluster/mod.rs`：
   - 顶部 `use anyhow::Result;`、`use crate::config::cluster::ClusterConfig;`
   - 声明 `pub mod templates;`
   - 声明 stub 入口 `pub async fn run(_args: &crate::cli::ClusterDeployArgs) -> Result<()> { unimplemented!("cluster::run 由 Plan 03 实现") }`（注意：`crate::cli::ClusterDeployArgs` 在 Plan 03 创建；本 Plan 暂用占位 `pub async fn run() -> Result<()> { unimplemented!() }` 无参数版本，Plan 03 改签名）
   - 实际写法：本 Plan 不引用 cli 类型，写 `pub async fn run() -> Result<()> { unimplemented!("cluster::run 由 Plan 03 实现") }`，Plan 03 修改此签名

3. 新建 `src/cluster/templates/mod.rs`：
   - 声明 `pub mod dm_ini; pub mod dmmal_ini; pub mod dmarch_ini; pub mod dmwatcher_ini;`
   - `pub use dm_ini::generate_dm_ini_cluster_suffix;`
   - `pub use dmmal_ini::generate_dmmal_ini;`
   - `pub use dmarch_ini::generate_dmarch_ini;`
   - `pub use dmwatcher_ini::generate_dmwatcher_ini;`

4. 新建 `src/cluster/templates/dm_ini.rs`，导出 `pub fn generate_dm_ini_cluster_suffix(node: &crate::config::cluster::NodeConfig) -> String`，返回字符串严格按以下格式（`\n` 换行，末尾无多余空行）：
   ```
   MAL_INI = 1
   ARCH_INI = 1
   ALTER_MODE_STATUS = 0
   ENABLE_OFFLINE_TS = 2
   ```
   （4 行，per RESEARCH §dm.ini 新增字段；参数 node 暂未使用但接受 `&NodeConfig` 以备未来扩展）
   - 文件末尾加 `#[cfg(test)] mod tests` 含 Test 6 对应测试

5. 新建 `src/cluster/templates/dmmal_ini.rs`，导出 `pub fn generate_dmmal_ini(nodes: &[crate::config::cluster::NodeConfig]) -> String`：
   - 用 `format!` 起始字符串 `"MAL_CHECK_INTERVAL = 5\nMAL_CONN_FAIL_INTERVAL = 5\n\n"`
   - 用 `nodes.iter().enumerate()` 循环，每个节点 push_str:
     `format!("[MAL_INST{}]\nMAL_INST_NAME = {}\nMAL_HOST = {}\nMAL_PORT = {}\nMAL_INST_HOST = {}\nMAL_INST_PORT = {}\nMAL_DW_PORT = {}\nMAL_INST_DW_PORT = {}\n\n", i+1, node.instance_name, node.host, node.mal_port, node.host, node.port, node.dw_port, node.inst_dw_port)`
   - 关键：函数对所有节点用同一调用顺序、同一字段引用，确保两次调用结果字节相等（Pitfall 1）
   - 末尾加 `#[cfg(test)] mod tests` 含 Test 1 对应测试，并明确断言 `let a = generate_dmmal_ini(&nodes); let b = generate_dmmal_ini(&nodes); assert_eq!(a, b)`

6. 新建 `src/cluster/templates/dmarch_ini.rs`，导出 `pub fn generate_dmarch_ini(node: &crate::config::cluster::NodeConfig, peer_instance: &str) -> String`：
   - 返回 `format!("[ARCHIVE_REALTIME]\nARCH_TYPE = REALTIME\nARCH_DEST = {}\n\n[ARCHIVE_LOCAL1]\nARCH_TYPE = LOCAL\nARCH_DEST = {}/arch\nARCH_FILE_SIZE = 128\nARCH_SPACE_LIMIT = 0\n", peer_instance, node.data_path)`
   - 末尾加 `#[cfg(test)] mod tests` 含 Test 2 / Test 3 对应测试

7. 新建 `src/cluster/templates/dmwatcher_ini.rs`，导出 `pub fn generate_dmwatcher_ini(node: &crate::config::cluster::NodeConfig, oguid: u32) -> String`：
   - INST_INI 字段使用 `format!("{}/{}/dm.ini", node.data_path, node.instance_name)`（Pitfall 3：备节点 INST_INI 必须指向自身 dm.ini）
   - 返回完整模板：
     `format!("[GRP1]\nDW_TYPE = GLOBAL\nDW_MODE = AUTO\nDW_ERROR_TIME = 10\nINST_RECOVER_TIME = 60\nINST_ERROR_TIME = 10\nINST_OGUID = {}\nINST_INI = {}/{}/dm.ini\nINST_AUTO_RESTART = 1\nINST_STARTUP_CMD = {}/bin/dmserver\nRLOG_SEND_THRESHOLD = 0\nRLOG_APPLY_THRESHOLD = 0\n", oguid, node.data_path, node.instance_name, node.install_path)`
   - 末尾加 `#[cfg(test)] mod tests` 含 Test 4 / Test 5 对应测试

8. 所有 `generate_*` 函数体保持在 < 40 行（CLAUDE.md universal rule）；若 dmmal_ini 的循环 + format 超长，把节点段生成提取为 `fn format_mal_inst(i: usize, node: &NodeConfig) -> String` private helper
  </action>
  <verify>
    <automated>cargo test --lib cluster::templates --no-fail-fast 2>&amp;1 | tee /tmp/p3-01-t2.log; grep -E "(test result: ok\.|FAILED)" /tmp/p3-01-t2.log</automated>
  </verify>
  <acceptance_criteria>
    - `cargo test --lib cluster::templates` exits 0
    - `cargo build --lib` exits 0（cluster::run stub 与 unimplemented!() 不应阻碍编译）
    - `src/cluster/mod.rs` 含 `pub mod templates;`（grep -q "^pub mod templates;" src/cluster/mod.rs）
    - `src/cluster/templates/mod.rs` 含至少 4 行 `pub use .*::generate_` re-export（grep -c "^pub use .*::generate_" src/cluster/templates/mod.rs 应 ≥ 4）
    - dmmal_ini.rs 测试至少含一处 `assert_eq!(a, b` 验证主备 bytes 相等
    - dmarch_ini.rs 测试至少含 `assert!(.*\.contains("ARCH_DEST = DMSVR02"))` 和 `assert!(.*\.contains("ARCH_DEST = DMSVR01"))` 两条断言（验证方向相反）
    - dmwatcher_ini.rs 测试至少含 `assert!(.*\.contains("DMSVR01/dm.ini"))` 验证 INST_INI 路径节点专属
    - 所有 generate_* 函数体行数 < 40（用 `awk` 或人工核验；若 helper 拆分到位则自动满足）
  </acceptance_criteria>
  <done>
    6 个模板单元测试全部通过；4 个 generate_*_ini 函数从 templates::mod 公开导出；dmmal 主备字节相等、dmarch 方向相反、dmwatcher INST_INI 路径节点专属、OGUID 严格一致——四个 Pitfall 防范点全部由测试覆盖。
  </done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| 用户编写的 cluster.toml → 安装器进程 | 不可信输入；TOML 反序列化与 validate 是首个防线 |
| 生成的 INI 配置字符串 → SFTP 推送（Plan 03） | 中间产物，需确保字段值经过 NodeConfig 验证（无 shell 元字符注入风险，因仅用于 INI 文件） |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-01 | Information Disclosure | SshCredentials.password 字段反序列化 | mitigate | `#[serde(skip_serializing)]` 防 Debug/Display/序列化输出泄漏（per RESEARCH §Security Domain） |
| T-03-02 | Tampering | TOML 输入越界值（如 oguid 超 i32 max、port=0） | mitigate | validate_cluster_config 显式 bail! 拒绝；不静默截断 |
| T-03-03 | Tampering | NodeConfig.host 字符串后续将拼入 SFTP path | accept (此计划) | 本计划不做 SSH/SFTP 操作；Plan 02/03 用 `PathBuf::join` 避免 shell 字符串拼接（per RESEARCH §Security Domain） |
| T-03-SC | Tampering | npm/cargo 依赖（本计划不引入新依赖） | N/A | 本 Plan 仅复用 Phase 2 已有 serde/toml/anyhow，无新装包 |
</threat_model>

<verification>
1. `cargo build --lib` exits 0
2. `cargo test --lib config::cluster` exits 0（Task 1 9 个测试）
3. `cargo test --lib cluster::templates` exits 0（Task 2 6 个测试）
4. `cargo test --lib` 整库测试通过（不破坏 Phase 2 的 InstallConfig 测试）
5. 手动核验 cluster_valid.toml 结构与 03-CONTEXT.md §Specific Ideas 示例一致
</verification>

<success_criteria>
- ClusterConfig schema 完成，含 NodeRole 枚举、NodeConfig（13 字段）、SshCredentials（user/identity_file/password，password 字段 skip_serializing）
- validate_cluster_config 覆盖：节点非空、恰好一个 primary、oguid 范围、port != 0、mal_port != port、SSH 凭据至少一种、page_size/charset/extent_size 域、instance_name 唯一
- 4 个 INI 模板函数全部完成并通过测试，覆盖 RESEARCH 列出的 4 个 Pitfall（dmmal 一致 / dmarch 方向 / dmwatcher INST_INI / OGUID 一致）
- 所有公共函数 < 40 行
- 不依赖 russh/SSH——纯数据/纯字符串生成，可在任何环境单元测试
</success_criteria>

<output>
Create `.planning/phases/03-cluster/03-01-SUMMARY.md` when done. Record:
- 实际创建的文件清单（包括行数）
- ClusterConfig 字段最终列表（如对 Plan 调整）
- 测试执行结果摘要（通过数 / 失败数）
- Plan 03 接管 cluster::run 入口的契约：当前签名 `pub async fn run() -> Result<()>`，Plan 03 改为 `pub async fn run(args: &crate::cli::ClusterDeployArgs) -> Result<()>`
</output>
</content>
</invoke>