# /container/deploy 容器创建请求参数说明

本文档说明 `iot` HTTP 接口 `POST /container/deploy` 当前支持的 JSON 参数、校验规则，以及服务端把 JSON 解码成 Erlang map 后如何转换成下发给 `efka` 的容器部署命令。

对应代码：

- HTTP 入口：[src/transport/http/container_handler.erl](/usr/local/code/cloudkit/iot/src/transport/http/container_handler.erl:63)
- HTTP JSON 解析：[src/transport/http/http_protocol.erl](/usr/local/code/cloudkit/iot/src/transport/http/http_protocol.erl:68)
- 参数校验与内部 map 构造：[src/docker/docker_container_builder.erl](/usr/local/code/cloudkit/iot/src/docker/docker_container_builder.erl:25)
- 主机命令下发：[src/host/iot_host.erl](/usr/local/code/cloudkit/iot/src/host/iot_host.erl:99)

## 1. 接口

```http
POST /container/deploy
Content-Type: application/json
```

HTTP body 必须是 JSON object。`http_protocol` 会使用 `json:decode/1` 解析请求体：

- JSON object -> Erlang map
- JSON array -> Erlang list
- JSON string -> Erlang binary
- JSON integer -> Erlang integer
- JSON float -> Erlang float
- JSON boolean -> Erlang `true | false`

`/container/deploy` handler 只接受顶层 map，且必须匹配：

```erlang
#{<<"uuid">> := UUID, <<"task_id">> := TaskId, <<"config">> := Config}
```

其中：

- `UUID` 必须是 binary，也就是 JSON string。
- `TaskId` 必须是 integer。
- `Config` 必须是 map，也就是 JSON object。

## 2. 完整 JSON 格式

下面示例包含当前支持的所有字段。实际请求可以只传必填字段和需要的可选字段。

```json
{
  "uuid": "qbxmjyzrkpntfgswaevodhluicqzxplkm",
  "task_id": 1001,
  "config": {
    "image": "docker.io/library/nginx:latest",
    "container_name": "my_nginx",
    "command": ["nginx", "-g", "daemon off;"],
    "restart": "always",
    "entrypoint": ["/docker-entrypoint.sh"],
    "envs": ["ENV=prod", "TZ=Asia/Shanghai"],
    "expose": ["80", "443/tcp", "53/udp"],
    "volumes": ["/host/data:/data", "/host/log:/var/log:ro"],
    "networks": ["bridge"],
    "network_mode": "bridge",
    "labels": {
      "app": "nginx",
      "env": "prod"
    },
    "user": "www-data",
    "working_dir": "/app",
    "hostname": "myhost",
    "privileged": false,
    "cap_add": ["NET_ADMIN"],
    "cap_drop": ["MKNOD"],
    "devices": ["/dev/ttyUSB0:/dev/ttyUSB0:rwm"],
    "mem_limit": "512m",
    "mem_reservation": "256m",
    "cpu_shares": 512,
    "cpus": 1.5,
    "ulimits": {
      "nofile": "1024:2048"
    },
    "sysctls": {
      "net.ipv4.ip_forward": "1"
    },
    "tmpfs": ["/tmp", "/run:rw,size=64m"],
    "extra_hosts": ["host.docker.internal:host-gateway"],
    "healthcheck": {
      "test": ["CMD-SHELL", "curl -f http://localhost || exit 1"],
      "interval": "30s",
      "timeout": "10s",
      "retries": 3
    }
  }
}
```

## 3. 顶层参数

| 字段 | JSON 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `uuid` | string | 是 | 目标 efka 所属主机 UUID。服务端用它查找 `iot_host` 进程，不会下发到 efka。 |
| `task_id` | non-negative integer | 是 | 部署任务 ID。会进入内部命令的 `task_id` 字段，用于关联部署结果和部署日志流。`iot` 内部使用 `{uuid, task_id}` 作为任务唯一标识。 |
| `config` | object | 是 | 容器创建配置。会被校验并转换成内部部署参数 map。 |

顶层没有 `timeout` 字段。当前 HTTP handler 等待 efka command response 的超时时间固定为 10 秒。

部署过程的实时反馈通过 SSE 读取：

```http
GET /event_stream?uuid=<host_uuid>&task_id=<task_id>
```

`/container/deploy` 收到合法请求后会先在 `iot` 内部创建或复用 `{uuid, task_id}` 对应的任务进程，然后再向 `efka` 下发部署命令。SSE handler 订阅同一个任务进程；该进程会缓存最近的部署事件，支持多个页面同时订阅，并在收到 efka 的 close 事件后关闭 SSE。

## 4. config 参数

### 必填字段

| 字段 | JSON 类型 | 内部类型 | 说明 |
| --- | --- | --- | --- |
| `image` | string | binary | 镜像名。没有 tag 时 efka 部署逻辑会补 `:latest`。 |
| `container_name` | string | binary | 容器名称。 |
| `command` | string[] | binary list | 容器启动命令，对应 Docker create config 的 `cmd`。 |
| `restart` | string | binary | 重启策略，例如 `no`、`always`、`unless-stopped`、`on-failure`、`on-failure:3`。 |

### 可选字段

| 字段 | JSON 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `entrypoint` | string[] | `[]` | Docker create config 的 `entrypoint`。 |
| `envs` | string[] | `[]` | 环境变量列表，例如 `["A=1"]`，对应 Docker create config 的 `env`。 |
| `expose` | string[] | `[]` | 容器暴露端口，只表示容器端口，不绑定宿主机端口。 |
| `ports` | string[] | `[]` | 宿主机到容器的端口映射，格式为 `host_port:container_port`，例如 `["8080:80", "443:443"]`。 |
| `volumes` | string[] | `[]` | volume bind 列表，格式见后文。 |
| `networks` | string[] | `[]` | Docker network 名称列表，用于 networking config。 |
| `network_mode` | string | `""` | Docker host config 的 `network_mode`。 |
| `labels` | object string:string | `{}` | 容器 labels。key/value 都必须是 string。 |
| `user` | string | `""` | 容器运行用户。 |
| `working_dir` | string | `""` | 容器工作目录。 |
| `hostname` | string | `""` | 容器 hostname。 |
| `privileged` | boolean | `false` | 是否 privileged。 |
| `cap_add` | string[] | `[]` | 追加 Linux capability。 |
| `cap_drop` | string[] | `[]` | 删除 Linux capability。 |
| `devices` | string[] | `[]` | 设备映射列表，格式见后文。 |
| `mem_limit` | string | `0` | 内存上限，解析成字节数。 |
| `mem_reservation` | string | `0` | 内存软限制，解析成字节数。 |
| `cpu_shares` | non-negative integer | `0` | Docker CPU shares。 |
| `cpus` | non-negative number | `0` | CPU 数量，转换成 nano cpus。 |
| `ulimits` | object string:string | `[]` | ulimit 配置，格式见后文。 |
| `sysctls` | object string:string | `{}` | sysctl 配置。key/value 都必须是 string。 |
| `tmpfs` | string[] | `{}` | tmpfs mount 配置，格式见后文。 |
| `extra_hosts` | string[] | `[]` | 额外 hosts，例如 `["host.docker.internal:host-gateway"]`。 |
| `healthcheck` | object | `undefined` | 健康检查配置，格式见后文。 |

### 不支持字段

| 字段 | 当前行为 | 说明 |
| --- | --- | --- |
| `container_dir` | 明确拒绝 | 容器目录由 efka 按系统默认规则管理，HTTP 调用方不能指定 efka 主机上的部署目录。 |
| `env_file` | 忽略 | 当前校验不会识别该字段，后续构造 Docker create options 时也不会使用。 |
| 其他未知字段 | 忽略 | 除 `container_dir` 外，未知字段不会报错，也不会进入内部部署参数。 |

## 5. 校验规则

校验分为两层。

第一层在 `container_handler`：

- `uuid` 必须是 binary。
- `task_id` 必须是非负 integer。
- `config` 必须是 map。

第二层在 `docker_container_builder:deploy_request/2`：

- 先拒绝不支持的 `container_dir` 字段。
- 检查必填字段是否存在。
- 检查已知字段类型。
- 检查 `healthcheck` 内部字段类型，避免无效嵌套字段进入构造阶段。
- 构造内部部署 map。
- 对需要解析的字段做格式解析，例如端口、volume、size、duration、ulimit。

类型错误会返回类似：

```text
required parameter: <<"command">>, type must be: list of string
optional parameter: <<"labels">>, type must be: map of string:string
```

缺少必填字段会返回类似：

```text
miss requied parameter: <<"image">>
```

注意：错误文本中的 `requied` 是当前代码里的原始拼写。

## 6. 内部转换结果

`config` 校验通过后，`docker_container_builder:deploy_request(TaskId, Config)` 生成：

```erlang
#{
    <<"action">> => <<"deploy">>,
    <<"task_id">> => TaskId,
    <<"params">> => #{
        <<"container_name">> => ContainerName,
        <<"create">> => #{
            <<"config">> => ContainerConfig,
            <<"host_config">> => HostConfig,
            <<"networking_config">> => NetworkingConfig
        }
    }
}
```

该 map 会通过 efka/iot 长连接协议下发：

```erlang
{<<"command">>, Ref, {<<"container">>, #{
    <<"action">> => <<"deploy">>,
    <<"task_id">> => TaskId,
    <<"params">> => Params
}}}
```

efka 返回：

```erlang
{<<"command_response">>, Ref, {<<"container">>, Reply}}
```

其中 `Ref` 是 `crypto:strong_rand_bytes(16)` 生成的 16 字节 binary。网络帧只使用 safe term，协议 label、command map key 和 action 使用 binary；`efka` 收到后直接按 binary key/action 处理。

HTTP handler 最多等待 10 秒。超时返回 HTTP 504，其他参数或执行错误通常返回 HTTP 400；找不到 host 返回业务错误 code 404。

## 7. Docker create config 映射

`create.config` 由 `build_docker_container_config/1` 生成：

| 内部字段 | 来源 JSON 字段 | 转换规则 |
| --- | --- | --- |
| `image` | `image` | 原值。 |
| `cmd` | `command` | 原值。 |
| `entrypoint` | `entrypoint` | 默认 `[]`。 |
| `env` | `envs` | 默认 `[]`。 |
| `labels` | `labels` | 默认 `{}`。 |
| `volumes` | `volumes` | 只保留 container path 列表。 |
| `user` | `user` | 默认 `""`。 |
| `working_dir` | `working_dir` | 默认 `""`。 |
| `hostname` | `hostname` | 默认 `""`。 |
| `exposed_ports` | `expose` + `ports` | 转成 `#{container_port, protocol}` map 列表；`ports` 中的容器端口会自动补进 `exposed_ports`。 |
| `healthcheck` | `healthcheck` | 未传时为 `undefined`。 |

## 8. Docker host config 映射

`create.host_config` 由 `build_docker_host_config/1` 生成：

| 内部字段 | 来源 JSON 字段 | 转换规则 |
| --- | --- | --- |
| `binds` | `volumes` | 转成 Docker bind 字符串列表。 |
| `network_mode` | `network_mode` | 默认 `""`。 |
| `restart_policy` | `restart` | 转成 `#{name, maximum_retry_count}`。 |
| `privileged` | `privileged` | 默认 `false`。 |
| `cap_add` | `cap_add` | 默认 `[]`。 |
| `cap_drop` | `cap_drop` | 默认 `[]`。 |
| `devices` | `devices` | 转成设备映射 map 列表。 |
| `memory` | `mem_limit` | 解析成字节数，未传为 `0`。 |
| `memory_reservation` | `mem_reservation` | 解析成字节数，未传为 `0`。 |
| `nano_cpus` | `cpus` | `cpus * 1000000000`，未传为 `0`。 |
| `cpu_shares` | `cpu_shares` | 未传为 `0`。 |
| `port_bindings` | `ports` | 转成 `#{host_ip, host_port, container_port, protocol}` map 列表。 |
| `ulimits` | `ulimits` | 转成 ulimit map 列表。 |
| `tmpfs` | `tmpfs` | 转成 map。 |
| `sysctls` | `sysctls` | 默认 `{}`。 |
| `extra_hosts` | `extra_hosts` | 默认 `[]`。 |

## 9. Docker networking config 映射

`create.networking_config` 由 `build_docker_networking_config/1` 生成：

```erlang
#{<<"endpoints">> => [#{<<"name">> => Network} || Network <- Networks]}
```

来源字段：

```json
"networks": ["bridge", "mynet"]
```

转换结果：

```erlang
#{<<"endpoints">> => [
    #{<<"name">> => <<"bridge">>},
    #{<<"name">> => <<"mynet">>}
]}
```

## 10. 复杂字段转换规则

### restart

输入：

```json
"restart": "always"
```

转换：

```erlang
#{<<"name">> => <<"always">>, <<"maximum_retry_count">> => 0}
```

输入：

```json
"restart": "on-failure:3"
```

转换：

```erlang
#{<<"name">> => <<"on-failure">>, <<"maximum_retry_count">> => 3}
```

### expose

输入：

```json
["80", "443/tcp", "53/udp"]
```

转换：

```erlang
[
    #{<<"container_port">> => 80, <<"protocol">> => <<"tcp">>},
    #{<<"container_port">> => 443, <<"protocol">> => <<"tcp">>},
    #{<<"container_port">> => 53, <<"protocol">> => <<"udp">>}
]
```

端口必须是无符号整数，且不能超过 `4294967295`。

### ports

输入：

```json
["8080:80", "443:443", "8053:53/udp"]
```

转换为 `create.host_config.port_bindings`：

```erlang
[
    #{<<"host_ip">> => <<>>, <<"host_port">> => 8080, <<"container_port">> => 80, <<"protocol">> => <<"tcp">>},
    #{<<"host_ip">> => <<>>, <<"host_port">> => 443, <<"container_port">> => 443, <<"protocol">> => <<"tcp">>},
    #{<<"host_ip">> => <<>>, <<"host_port">> => 8053, <<"container_port">> => 53, <<"protocol">> => <<"udp">>}
]
```

规则：

- 端口映射之间使用冒号分隔：`host_port:container_port`。
- 容器端口可以带协议：`host_port:container_port/protocol`。
- 未指定协议时默认为 `tcp`。
- host port 和 container port 都不能为空，且必须是 `0..65535` 范围内的无符号整数。
- `ports` 中出现的容器端口会自动补进 `create.config.exposed_ports`，因此不需要在 `expose` 里重复声明。

### volumes

输入：

```json
["/host/data:/data", "/host/log:/var/log:ro", "/host/cache:/cache:rw"]
```

转换为 `create.config.volumes`：

```erlang
[<<"/data">>, <<"/var/log">>, <<"/cache">>]
```

转换为 `create.host_config.binds`：

```erlang
[
    <<"/host/data:/data">>,
    <<"/host/log:/var/log:ro">>,
    <<"/host/cache:/cache">>
]
```

规则：

- `host_path:container_path` -> 读写挂载。
- `host_path:container_path:ro` -> 只读挂载。
- `host_path:container_path:rw` -> 当前实现视为读写挂载，输出时不会保留 `:rw`。
- host path 和 container path 都不能为空。

### devices

输入：

```json
["/dev/ttyUSB0:/dev/ttyUSB0", "/dev/snd:/dev/snd:rwm"]
```

转换：

```erlang
[
    #{
        <<"path_on_host">> => <<"/dev/ttyUSB0">>,
        <<"path_in_container">> => <<"/dev/ttyUSB0">>,
        <<"cgroup_permissions">> => <<"rwm">>
    },
    #{
        <<"path_on_host">> => <<"/dev/snd">>,
        <<"path_in_container">> => <<"/dev/snd">>,
        <<"cgroup_permissions">> => <<"rwm">>
    }
]
```

规则：

- `host_path:container_path` -> 权限默认为 `rwm`。
- `host_path:container_path:permissions` -> 使用第三段作为权限。
- 任意路径或 permissions 为空时返回 `invalid device mapping`。

### ulimits

输入：

```json
{
  "nofile": "1024:2048",
  "nproc": "4096"
}
```

转换：

```erlang
[
    #{<<"name">> => <<"nofile">>, <<"soft">> => 1024, <<"hard">> => 2048},
    #{<<"name">> => <<"nproc">>, <<"soft">> => 4096, <<"hard">> => 4096}
]
```

规则：

- `"soft:hard"` -> 分别设置 soft 和 hard。
- `"limit"` -> soft 和 hard 都等于 limit。
- 数值必须是无符号整数。

### tmpfs

输入：

```json
["/tmp", "/run:rw,size=64m"]
```

转换：

```erlang
#{
    <<"/tmp">> => <<>>,
    <<"/run">> => <<"rw,size=64m">>
}
```

规则：

- `"path"` -> options 为 `""`。
- `"path:options"` -> options 为第二段。
- path 不能为空。

### healthcheck

输入：

```json
{
  "test": ["CMD-SHELL", "curl -f http://localhost || exit 1"],
  "interval": "30s",
  "timeout": "10s",
  "retries": 3
}
```

转换：

```erlang
#{
    <<"test">> => [<<"CMD-SHELL">>, <<"curl -f http://localhost || exit 1">>],
    <<"interval_ns">> => 30000000000,
    <<"timeout_ns">> => 10000000000,
    <<"retries">> => 3
}
```

字段规则：

| 字段 | JSON 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `test` | string[] | `[]` | Docker healthcheck test 参数列表。 |
| `interval` | string 或 non-negative integer | `"0s"` | string 会解析时间单位；integer 直接视为纳秒。 |
| `timeout` | string 或 non-negative integer | `"0s"` | string 会解析时间单位；integer 直接视为纳秒。 |
| `retries` | non-negative integer | `0` | Docker healthcheck 重试次数。 |

时间单位：

| 单位 | 含义 |
| --- | --- |
| `ns` | 纳秒 |
| `us` | 微秒 |
| `ms` | 毫秒 |
| `s` | 秒 |
| `m` | 分钟 |
| `h` | 小时 |
| 无单位 | 秒 |

示例：

- `"30s"` -> `30000000000`
- `"10ms"` -> `10000000`
- `"2m"` -> `120000000000`
- `"5"` -> `5000000000`

### mem_limit 和 mem_reservation

输入：

```json
"mem_limit": "512m",
"mem_reservation": "1g"
```

转换：

```erlang
<<"memory">> => 536870912,
<<"memory_reservation">> => 1073741824
```

支持单位：

| 单位 | 倍数 |
| --- | --- |
| `b` 或无单位 | 1 |
| `k`, `kb`, `ki`, `kib` | 1024 |
| `m`, `mb`, `mi`, `mib` | 1048576 |
| `g`, `gb`, `gi`, `gib` | 1073741824 |
| `t`, `tb`, `ti`, `tib` | 1099511627776 |

数值支持整数或小数，例如 `"1.5g"`。

### cpus

输入：

```json
"cpus": 1.5
```

转换：

```erlang
<<"nano_cpus">> => 1500000000
```

规则：

- integer 或 float 都可以。
- 必须大于等于 0。
- 转换公式：`trunc(Cpus * 1000000000)`。

## 11. 最小请求示例

```json
{
  "uuid": "qbxmjyzrkpntfgswaevodhluicqzxplkm",
  "task_id": 1001,
  "config": {
    "image": "docker.io/library/nginx:latest",
    "container_name": "my_nginx",
    "command": ["nginx", "-g", "daemon off;"],
    "restart": "always"
  }
}
```

对应下发给 `efka` 的协议命令示意：

```erlang
{<<"command">>, Ref, {<<"container">>, #{
    <<"action">> => <<"deploy">>,
    <<"task_id">> => 1001,
    <<"params">> => #{
        <<"container_name">> => <<"my_nginx">>,
        <<"create">> => #{
            <<"config">> => #{
                <<"image">> => <<"docker.io/library/nginx:latest">>,
                <<"cmd">> => [<<"nginx">>, <<"-g">>, <<"daemon off;">>],
                <<"entrypoint">> => [],
                <<"env">> => [],
                <<"labels">> => #{},
                <<"volumes">> => [],
                <<"user">> => <<>>,
                <<"working_dir">> => <<>>,
                <<"hostname">> => <<>>,
                <<"exposed_ports">> => [],
                <<"healthcheck">> => undefined
            },
            <<"host_config">> => #{
                <<"binds">> => [],
                <<"network_mode">> => <<>>,
                <<"restart_policy">> => #{<<"name">> => <<"always">>, <<"maximum_retry_count">> => 0},
                <<"privileged">> => false,
                <<"cap_add">> => [],
                <<"cap_drop">> => [],
                <<"devices">> => [],
                <<"memory">> => 0,
                <<"memory_reservation">> => 0,
                <<"nano_cpus">> => 0,
                <<"cpu_shares">> => 0,
                <<"port_bindings">> => [],
                <<"ulimits">> => [],
                <<"tmpfs">> => #{},
                <<"sysctls">> => #{},
                <<"extra_hosts">> => []
            },
            <<"networking_config">> => #{<<"endpoints">> => []}
        }
    }
}}}
```

## 12. 响应

成功时 HTTP body 由 `iot_util:json_data/1` 包装：

```json
{
  "result": "ok"
}
```

如果 efka 返回的是 JSON binary，handler 会尝试解码后再放入 `result`。

错误示例：

```json
{
  "error": {
    "code": 400,
    "message": "invalid port binding"
  }
}
```

常见状态：

| HTTP 状态 | 场景 |
| --- | --- |
| `200` | host not found 或构造阶段返回的业务错误也可能通过 200 包装业务错误。 |
| `400` | 参数校验失败、efka 返回普通错误。 |
| `504` | 等待 efka command response 超时。 |
