fix
This commit is contained in:
parent
881284c79c
commit
251acb9b17
519
docs/container_deploy_request.md
Normal file
519
docs/container_deploy_request.md
Normal file
@ -0,0 +1,519 @@
|
|||||||
|
# Container Deploy 请求格式说明
|
||||||
|
|
||||||
|
本文档说明 HTTP 接口 `/container/deploy` 接收的 JSON 格式,以及它在服务端如何被转换为 `message.proto` 中的 `ContainerRequest.Deploy` / `ContainerDeployParams` / `ContainerSpec`。
|
||||||
|
|
||||||
|
当前实现对应代码:
|
||||||
|
|
||||||
|
- HTTP 入口校验:[src/transport/http/container_handler.erl](/usr/local/code/cloudkit/iot/src/transport/http/container_handler.erl:63)
|
||||||
|
- 请求构造器:[src/host/iot_container_request_builder.erl](/usr/local/code/cloudkit/iot/src/host/iot_container_request_builder.erl:25)
|
||||||
|
- protobuf 定义:[proto/message.proto](/usr/local/code/cloudkit/iot/proto/message.proto:37)
|
||||||
|
|
||||||
|
## 1. HTTP 请求格式
|
||||||
|
|
||||||
|
接口:
|
||||||
|
|
||||||
|
- `POST /container/deploy`
|
||||||
|
|
||||||
|
请求体顶层结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "host-uuid",
|
||||||
|
"task_id": 1001,
|
||||||
|
"config": {
|
||||||
|
"image": "docker.io/library/nginx:latest",
|
||||||
|
"container_name": "my_nginx",
|
||||||
|
"container_dir": "/data/apps/my_nginx",
|
||||||
|
"command": ["nginx", "-g", "daemon off;"],
|
||||||
|
"entrypoint": ["/docker-entrypoint.sh"],
|
||||||
|
"envs": ["ENV1=val1", "ENV2=val2"],
|
||||||
|
"expose": ["80", "443/tcp"],
|
||||||
|
"volumes": ["/host/data:/data", "/host/log:/var/log:ro"],
|
||||||
|
"networks": ["bridge"],
|
||||||
|
"network_mode": "bridge",
|
||||||
|
"labels": {
|
||||||
|
"role": "web",
|
||||||
|
"env": "prod"
|
||||||
|
},
|
||||||
|
"restart": "always",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 顶层字段与 protobuf 的关系
|
||||||
|
|
||||||
|
HTTP 顶层字段和 protobuf 请求体的关系如下:
|
||||||
|
|
||||||
|
| HTTP 字段 | 类型 | 目标 protobuf 字段 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `uuid` | `string` | 不进入 protobuf body | 仅用于在服务端定位 `iot_host` 进程 |
|
||||||
|
| `task_id` | `integer` | `ContainerRequest.Deploy.task_id` | 必填,用于异步消息集合标识 |
|
||||||
|
| `config` | `object` | `ContainerRequest.Deploy.params` | 必填,转换成 `ContainerDeployParams` |
|
||||||
|
|
||||||
|
最终构造出来的请求体结构是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ContainerRequest {
|
||||||
|
action = {
|
||||||
|
deploy,
|
||||||
|
ContainerRequest.Deploy {
|
||||||
|
task_id = TaskId,
|
||||||
|
params = ContainerDeployParams {...}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. `config` 到 `ContainerDeployParams` 的映射
|
||||||
|
|
||||||
|
`config` 会被转换成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ContainerDeployParams {
|
||||||
|
container_name,
|
||||||
|
container_dir,
|
||||||
|
spec = ContainerSpec {...}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段映射如下:
|
||||||
|
|
||||||
|
| `config` 字段 | 类型 | protobuf 字段 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `container_name` | `string` | `ContainerDeployParams.container_name` | 必填 |
|
||||||
|
| `container_dir` | `string` | `ContainerDeployParams.container_dir` | 可选,默认 `""` |
|
||||||
|
| 其余部署字段 | 多种 | `ContainerDeployParams.spec` | 转成 `ContainerSpec` |
|
||||||
|
|
||||||
|
## 4. `config` 到 `ContainerSpec` 的详细映射
|
||||||
|
|
||||||
|
### 4.1 直接映射字段
|
||||||
|
|
||||||
|
以下字段基本按原值写入:
|
||||||
|
|
||||||
|
| `config` 字段 | 类型 | protobuf 字段 | 默认值 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `image` | `string` | `ContainerSpec.image` | 必填 |
|
||||||
|
| `command` | `string[]` | `ContainerSpec.command` | 必填 |
|
||||||
|
| `entrypoint` | `string[]` | `ContainerSpec.entrypoint` | `[]` |
|
||||||
|
| `envs` | `string[]` | `ContainerSpec.env` | `[]` |
|
||||||
|
| `networks` | `string[]` | `ContainerSpec.networks` | `[]` |
|
||||||
|
| `network_mode` | `string` | `ContainerSpec.network_mode` | `""` |
|
||||||
|
| `user` | `string` | `ContainerSpec.user` | `""` |
|
||||||
|
| `working_dir` | `string` | `ContainerSpec.working_dir` | `""` |
|
||||||
|
| `hostname` | `string` | `ContainerSpec.hostname` | `""` |
|
||||||
|
| `privileged` | `boolean` | `ContainerSpec.privileged` | `false` |
|
||||||
|
| `cap_add` | `string[]` | `ContainerSpec.cap_add` | `[]` |
|
||||||
|
| `cap_drop` | `string[]` | `ContainerSpec.cap_drop` | `[]` |
|
||||||
|
| `extra_hosts` | `string[]` | `ContainerSpec.extra_hosts` | `[]` |
|
||||||
|
|
||||||
|
### 4.2 map 转换字段
|
||||||
|
|
||||||
|
| `config` 字段 | 类型 | protobuf 字段 | 转换方式 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `labels` | `map<string,string>` | `ContainerSpec.labels` | 转成 key/value 列表 |
|
||||||
|
| `sysctls` | `map<string,string>` | `ContainerSpec.sysctls` | 转成 key/value 列表 |
|
||||||
|
|
||||||
|
### 4.3 结构化转换字段
|
||||||
|
|
||||||
|
#### `volumes`
|
||||||
|
|
||||||
|
输入类型:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["/host/data:/data", "/host/log:/var/log:ro"]
|
||||||
|
```
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ContainerSpec.volumes = [VolumeBind...]
|
||||||
|
```
|
||||||
|
|
||||||
|
转换规则:
|
||||||
|
|
||||||
|
- `host_path:container_path`
|
||||||
|
- `read_only = false`
|
||||||
|
- `host_path:container_path:ro`
|
||||||
|
- `read_only = true`
|
||||||
|
- `host_path:container_path:rw`
|
||||||
|
- 当前实现也会被接受,但 `read_only = false`
|
||||||
|
|
||||||
|
生成结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
VolumeBind {
|
||||||
|
host_path,
|
||||||
|
container_path,
|
||||||
|
read_only
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `expose`
|
||||||
|
|
||||||
|
输入类型:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["80", "443/tcp", "53/udp"]
|
||||||
|
```
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ContainerSpec.expose = [PortExpose...]
|
||||||
|
```
|
||||||
|
|
||||||
|
转换规则:
|
||||||
|
|
||||||
|
- `"80"` -> `container_port = 80`, `protocol = "tcp"`
|
||||||
|
- `"443/tcp"` -> `container_port = 443`, `protocol = "tcp"`
|
||||||
|
- `"53/udp"` -> `container_port = 53`, `protocol = "udp"`
|
||||||
|
|
||||||
|
生成结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PortExpose {
|
||||||
|
container_port,
|
||||||
|
protocol
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `devices`
|
||||||
|
|
||||||
|
输入类型:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["/dev/ttyUSB0:/dev/ttyUSB0", "/dev/snd:/dev/snd:rwm"]
|
||||||
|
```
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ContainerSpec.devices = [DeviceMapping...]
|
||||||
|
```
|
||||||
|
|
||||||
|
转换规则:
|
||||||
|
|
||||||
|
- `host_path:container_path`
|
||||||
|
- `cgroup_permissions = "rwm"`
|
||||||
|
- `host_path:container_path:permissions`
|
||||||
|
- 使用第三段作为 `cgroup_permissions`
|
||||||
|
|
||||||
|
#### `ulimits`
|
||||||
|
|
||||||
|
输入类型:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nofile": "1024:2048",
|
||||||
|
"nproc": "4096"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ContainerSpec.ulimits = [Ulimit...]
|
||||||
|
```
|
||||||
|
|
||||||
|
转换规则:
|
||||||
|
|
||||||
|
- `"1024:2048"` -> `soft = 1024`, `hard = 2048`
|
||||||
|
- `"4096"` -> `soft = 4096`, `hard = 4096`
|
||||||
|
|
||||||
|
#### `tmpfs`
|
||||||
|
|
||||||
|
输入类型:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["/tmp", "/run:rw,size=64m"]
|
||||||
|
```
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ContainerSpec.tmpfs = [TmpfsMount...]
|
||||||
|
```
|
||||||
|
|
||||||
|
转换规则:
|
||||||
|
|
||||||
|
- `"path"` -> `options = ""`
|
||||||
|
- `"path:options"` -> `options` 为第二段
|
||||||
|
|
||||||
|
#### `healthcheck`
|
||||||
|
|
||||||
|
输入类型:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"test": ["CMD-SHELL", "curl -f http://localhost || exit 1"],
|
||||||
|
"interval": "30s",
|
||||||
|
"timeout": "10s",
|
||||||
|
"retries": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ContainerSpec.healthcheck = Healthcheck {
|
||||||
|
test,
|
||||||
|
interval_ns,
|
||||||
|
timeout_ns,
|
||||||
|
retries
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
转换规则:
|
||||||
|
|
||||||
|
- `test` 直接写入 `Healthcheck.test`
|
||||||
|
- `interval` / `timeout` 会被解析成纳秒
|
||||||
|
- `retries` 直接写入
|
||||||
|
|
||||||
|
支持的时间单位:
|
||||||
|
|
||||||
|
- `ns`
|
||||||
|
- `us`
|
||||||
|
- `ms`
|
||||||
|
- `s`
|
||||||
|
- `m`
|
||||||
|
- `h`
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
- `"30s"` -> `30000000000`
|
||||||
|
- `"10ms"` -> `10000000`
|
||||||
|
- `"2m"` -> `120000000000`
|
||||||
|
|
||||||
|
### 4.4 资源限制字段
|
||||||
|
|
||||||
|
#### `restart`
|
||||||
|
|
||||||
|
输入类型:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"always"
|
||||||
|
```
|
||||||
|
|
||||||
|
或:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"on-failure:3"
|
||||||
|
```
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
```text
|
||||||
|
RestartPolicy {
|
||||||
|
name,
|
||||||
|
maximum_retry_count
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
转换规则:
|
||||||
|
|
||||||
|
- `"always"` -> `name = "always"`, `maximum_retry_count = 0`
|
||||||
|
- `"on-failure:3"` -> `name = "on-failure"`, `maximum_retry_count = 3`
|
||||||
|
|
||||||
|
#### `mem_limit` / `mem_reservation`
|
||||||
|
|
||||||
|
目标字段:
|
||||||
|
|
||||||
|
- `ResourceLimits.memory_bytes`
|
||||||
|
- `ResourceLimits.memory_reservation_bytes`
|
||||||
|
|
||||||
|
支持单位:
|
||||||
|
|
||||||
|
- `b`
|
||||||
|
- `k`, `kb`, `ki`, `kib`
|
||||||
|
- `m`, `mb`, `mi`, `mib`
|
||||||
|
- `g`, `gb`, `gi`, `gib`
|
||||||
|
- `t`, `tb`, `ti`, `tib`
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
- `"512m"` -> `536870912`
|
||||||
|
- `"1g"` -> `1073741824`
|
||||||
|
|
||||||
|
#### `cpus`
|
||||||
|
|
||||||
|
目标字段:
|
||||||
|
|
||||||
|
- `ResourceLimits.nano_cpus`
|
||||||
|
|
||||||
|
转换规则:
|
||||||
|
|
||||||
|
- 直接乘以 `1_000_000_000`
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
- `1` -> `1000000000`
|
||||||
|
- `1.5` -> `1500000000`
|
||||||
|
|
||||||
|
#### `cpu_shares`
|
||||||
|
|
||||||
|
目标字段:
|
||||||
|
|
||||||
|
- `ResourceLimits.cpu_shares`
|
||||||
|
|
||||||
|
直接按整数写入。
|
||||||
|
|
||||||
|
#### `resources` 对象生成规则
|
||||||
|
|
||||||
|
只有在以下字段至少存在一个时,才会生成 `ContainerSpec.resources`:
|
||||||
|
|
||||||
|
- `mem_limit`
|
||||||
|
- `mem_reservation`
|
||||||
|
- `cpus`
|
||||||
|
- `cpu_shares`
|
||||||
|
|
||||||
|
如果这些字段都不存在,则 `resources = undefined`。
|
||||||
|
|
||||||
|
## 5. 参数校验规则
|
||||||
|
|
||||||
|
在 HTTP 层,`config` 会先经过基础类型校验。
|
||||||
|
|
||||||
|
必填字段:
|
||||||
|
|
||||||
|
- `image: string`
|
||||||
|
- `container_name: string`
|
||||||
|
- `command: string[]`
|
||||||
|
- `restart: string`
|
||||||
|
|
||||||
|
可选字段:
|
||||||
|
|
||||||
|
- `privileged: boolean`
|
||||||
|
- `entrypoint: string[]`
|
||||||
|
- `envs: string[]`
|
||||||
|
- `ports: string[]`
|
||||||
|
- `expose: string[]`
|
||||||
|
- `volumes: string[]`
|
||||||
|
- `networks: string[]`
|
||||||
|
- `labels: map<string,string>`
|
||||||
|
- `user: string`
|
||||||
|
- `working_dir: string`
|
||||||
|
- `hostname: string`
|
||||||
|
- `container_dir: string`
|
||||||
|
- `network_mode: string`
|
||||||
|
- `cap_add: string[]`
|
||||||
|
- `cap_drop: string[]`
|
||||||
|
- `devices: string[]`
|
||||||
|
- `mem_limit: string`
|
||||||
|
- `mem_reservation: string`
|
||||||
|
- `cpu_shares: integer`
|
||||||
|
- `cpus: number`
|
||||||
|
- `ulimits: map<string,string>`
|
||||||
|
- `sysctls: map<string,string>`
|
||||||
|
- `tmpfs: string[]`
|
||||||
|
- `extra_hosts: string[]`
|
||||||
|
- `healthcheck: map<string,any>`
|
||||||
|
|
||||||
|
## 6. 当前限制
|
||||||
|
|
||||||
|
### `ports`
|
||||||
|
|
||||||
|
虽然 HTTP 校验允许 `ports` 字段出现,但当前 builder 仍然会拒绝它:
|
||||||
|
|
||||||
|
```text
|
||||||
|
unsupported container config keys: ports
|
||||||
|
```
|
||||||
|
|
||||||
|
原因是当前 `message.proto` 里只有:
|
||||||
|
|
||||||
|
- `ContainerSpec.expose`
|
||||||
|
|
||||||
|
它表达的是容器端口暴露,不包含主机端口绑定信息;而 `ports` 一般是类似 `8080:80` 的 host/container 绑定语义,两者并不等价。
|
||||||
|
|
||||||
|
### `env_file`
|
||||||
|
|
||||||
|
当前实现不再接收 `env_file`。如果传入,HTTP 层就不会通过类型校验,因为它不在 `validate_config/1` 的允许字段列表中。
|
||||||
|
|
||||||
|
## 7. 推荐请求示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "qbxmjyzrkpntfgswaevodhluicqzxplkm",
|
||||||
|
"task_id": 1001,
|
||||||
|
"config": {
|
||||||
|
"image": "docker.io/library/nginx:latest",
|
||||||
|
"container_name": "my_nginx",
|
||||||
|
"container_dir": "/data/apps/my_nginx",
|
||||||
|
"command": ["nginx", "-g", "daemon off;"],
|
||||||
|
"entrypoint": ["/docker-entrypoint.sh"],
|
||||||
|
"envs": ["ENV=prod", "TZ=Asia/Shanghai"],
|
||||||
|
"expose": ["80", "443/tcp"],
|
||||||
|
"volumes": ["/host/data:/data", "/host/log:/var/log:ro"],
|
||||||
|
"networks": ["bridge"],
|
||||||
|
"network_mode": "bridge",
|
||||||
|
"labels": {
|
||||||
|
"app": "nginx",
|
||||||
|
"env": "prod"
|
||||||
|
},
|
||||||
|
"restart": "always",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 后续如果要支持 `ports`
|
||||||
|
|
||||||
|
建议先扩展 `message.proto`,增加明确表达 host/container 端口绑定的结构,例如:
|
||||||
|
|
||||||
|
```proto
|
||||||
|
message PortBinding {
|
||||||
|
uint32 host_port = 1;
|
||||||
|
uint32 container_port = 2;
|
||||||
|
string protocol = 3;
|
||||||
|
string host_ip = 4;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
然后再在 `ContainerSpec` 中增加类似 `repeated PortBinding ports = ...;` 的字段,再由 builder 把 `"8080:80/tcp"` 这类字符串解析进去。这样语义才完整。
|
||||||
358
src/host/iot_container_request_builder.erl
Normal file
358
src/host/iot_container_request_builder.erl
Normal file
@ -0,0 +1,358 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @author
|
||||||
|
%%% @copyright (C) 2026, <COMPANY>
|
||||||
|
%%% @doc
|
||||||
|
%%% ContainerRequest protobuf builder helpers.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(iot_container_request_builder).
|
||||||
|
|
||||||
|
-include("message_pb.hrl").
|
||||||
|
|
||||||
|
-export([list_request/0, config_request/2, deploy_request/2, start_request/1, stop_request/1, kill_request/1, remove_request/1]).
|
||||||
|
|
||||||
|
-spec list_request() -> message_pb:'ContainerRequest'().
|
||||||
|
list_request() ->
|
||||||
|
#'ContainerRequest'{action = {list, #'ContainerRequest.List'{all = true}}}.
|
||||||
|
|
||||||
|
-spec config_request(ContainerName :: binary(), ConfigJson :: binary()) -> message_pb:'ContainerRequest'().
|
||||||
|
config_request(ContainerName, ConfigJson) when is_binary(ContainerName), is_binary(ConfigJson) ->
|
||||||
|
#'ContainerRequest'{action = {config, #'ContainerRequest.Config'{
|
||||||
|
target = container_ref(ContainerName),
|
||||||
|
config = ConfigJson
|
||||||
|
}}}.
|
||||||
|
|
||||||
|
-spec deploy_request(TaskId :: integer(), Config :: map()) ->
|
||||||
|
{ok, message_pb:'ContainerRequest'()} | {error, binary()}.
|
||||||
|
deploy_request(TaskId, Config) when is_integer(TaskId), is_map(Config), TaskId >= 0 ->
|
||||||
|
try
|
||||||
|
ensure_supported_deploy_config(Config),
|
||||||
|
Params = build_container_deploy_params(Config),
|
||||||
|
{ok, #'ContainerRequest'{action = {deploy, #'ContainerRequest.Deploy'{task_id = TaskId, params = Params}}}}
|
||||||
|
catch
|
||||||
|
throw:{error, Reason} ->
|
||||||
|
{error, Reason}
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec start_request(ContainerName :: binary()) -> message_pb:'ContainerRequest'().
|
||||||
|
start_request(ContainerName) when is_binary(ContainerName) ->
|
||||||
|
#'ContainerRequest'{action = {start, #'ContainerRequest.Start'{target = container_ref(ContainerName)}}}.
|
||||||
|
|
||||||
|
-spec stop_request(ContainerName :: binary()) -> message_pb:'ContainerRequest'().
|
||||||
|
stop_request(ContainerName) when is_binary(ContainerName) ->
|
||||||
|
#'ContainerRequest'{action = {stop, #'ContainerRequest.Stop'{
|
||||||
|
target = container_ref(ContainerName),
|
||||||
|
timeout_seconds = 0
|
||||||
|
}}}.
|
||||||
|
|
||||||
|
-spec kill_request(ContainerName :: binary()) -> message_pb:'ContainerRequest'().
|
||||||
|
kill_request(ContainerName) when is_binary(ContainerName) ->
|
||||||
|
#'ContainerRequest'{action = {kill, #'ContainerRequest.Kill'{
|
||||||
|
target = container_ref(ContainerName),
|
||||||
|
signal = <<>>
|
||||||
|
}}}.
|
||||||
|
|
||||||
|
-spec remove_request(ContainerName :: binary()) -> message_pb:'ContainerRequest'().
|
||||||
|
remove_request(ContainerName) when is_binary(ContainerName) ->
|
||||||
|
#'ContainerRequest'{action = {remove, #'ContainerRequest.Remove'{
|
||||||
|
target = container_ref(ContainerName),
|
||||||
|
force = false,
|
||||||
|
remove_volumes = false
|
||||||
|
}}}.
|
||||||
|
|
||||||
|
-spec container_ref(ContainerName :: binary()) -> message_pb:'ContainerRef'().
|
||||||
|
container_ref(ContainerName) when is_binary(ContainerName) ->
|
||||||
|
#'ContainerRef'{name = ContainerName}.
|
||||||
|
|
||||||
|
-spec ensure_supported_deploy_config(Config :: map()) -> ok.
|
||||||
|
ensure_supported_deploy_config(Config) when is_map(Config) ->
|
||||||
|
UnsupportedKeys = [Key || Key <- [<<"ports">>], maps:is_key(Key, Config)],
|
||||||
|
case UnsupportedKeys of
|
||||||
|
[] ->
|
||||||
|
ok;
|
||||||
|
_ ->
|
||||||
|
Unsupported = iolist_to_binary(lists:join(<<", ">>, UnsupportedKeys)),
|
||||||
|
throw({error, <<"unsupported container config keys: ", Unsupported/binary>>})
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec build_container_deploy_params(Config :: map()) -> message_pb:'ContainerDeployParams'().
|
||||||
|
build_container_deploy_params(Config) when is_map(Config) ->
|
||||||
|
ContainerName = maps:get(<<"container_name">>, Config),
|
||||||
|
ContainerDir = maps:get(<<"container_dir">>, Config, <<>>),
|
||||||
|
Spec = build_container_spec(Config),
|
||||||
|
#'ContainerDeployParams'{
|
||||||
|
container_name = ContainerName,
|
||||||
|
container_dir = ContainerDir,
|
||||||
|
spec = Spec
|
||||||
|
}.
|
||||||
|
|
||||||
|
-spec build_container_spec(Config :: map()) -> message_pb:'ContainerSpec'().
|
||||||
|
build_container_spec(Config) when is_map(Config) ->
|
||||||
|
Volumes = build_volume_binds(maps:get(<<"volumes">>, Config, [])),
|
||||||
|
Expose = build_port_exposes(maps:get(<<"expose">>, Config, [])),
|
||||||
|
Restart = build_restart_policy(maps:get(<<"restart">>, Config)),
|
||||||
|
Devices = build_device_mappings(maps:get(<<"devices">>, Config, [])),
|
||||||
|
Resources = build_resource_limits(Config),
|
||||||
|
Ulimits = build_ulimits(maps:get(<<"ulimits">>, Config, #{})),
|
||||||
|
Tmpfs = build_tmpfs_mounts(maps:get(<<"tmpfs">>, Config, [])),
|
||||||
|
Healthcheck = build_healthcheck(maps:get(<<"healthcheck">>, Config, undefined)),
|
||||||
|
#'ContainerSpec'{
|
||||||
|
image = maps:get(<<"image">>, Config),
|
||||||
|
command = maps:get(<<"command">>, Config),
|
||||||
|
entrypoint = maps:get(<<"entrypoint">>, Config, []),
|
||||||
|
env = maps:get(<<"envs">>, Config, []),
|
||||||
|
labels = maps:to_list(maps:get(<<"labels">>, Config, #{})),
|
||||||
|
volumes = Volumes,
|
||||||
|
user = maps:get(<<"user">>, Config, <<>>),
|
||||||
|
working_dir = maps:get(<<"working_dir">>, Config, <<>>),
|
||||||
|
hostname = maps:get(<<"hostname">>, Config, <<>>),
|
||||||
|
expose = Expose,
|
||||||
|
networks = maps:get(<<"networks">>, Config, []),
|
||||||
|
network_mode = maps:get(<<"network_mode">>, Config, <<>>),
|
||||||
|
healthcheck = Healthcheck,
|
||||||
|
restart = Restart,
|
||||||
|
privileged = maps:get(<<"privileged">>, Config, false),
|
||||||
|
cap_add = maps:get(<<"cap_add">>, Config, []),
|
||||||
|
cap_drop = maps:get(<<"cap_drop">>, Config, []),
|
||||||
|
devices = Devices,
|
||||||
|
resources = Resources,
|
||||||
|
ulimits = Ulimits,
|
||||||
|
tmpfs = Tmpfs,
|
||||||
|
sysctls = maps:to_list(maps:get(<<"sysctls">>, Config, #{})),
|
||||||
|
extra_hosts = maps:get(<<"extra_hosts">>, Config, [])
|
||||||
|
}.
|
||||||
|
|
||||||
|
-spec build_restart_policy(binary()) -> message_pb:'RestartPolicy'().
|
||||||
|
build_restart_policy(Restart0) when is_binary(Restart0) ->
|
||||||
|
case binary:split(Restart0, <<":">>) of
|
||||||
|
[Name, RetryCountBin] ->
|
||||||
|
#'RestartPolicy'{name = Name, maximum_retry_count = parse_uint32(RetryCountBin, <<"restart">>)};
|
||||||
|
[Name] ->
|
||||||
|
#'RestartPolicy'{name = Name, maximum_retry_count = 0}
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec build_healthcheck(undefined | map()) -> undefined | message_pb:'Healthcheck'().
|
||||||
|
build_healthcheck(undefined) ->
|
||||||
|
undefined;
|
||||||
|
build_healthcheck(Healthcheck) when is_map(Healthcheck) ->
|
||||||
|
#'Healthcheck'{
|
||||||
|
test = maps:get(<<"test">>, Healthcheck, []),
|
||||||
|
interval_ns = parse_duration_ns(maps:get(<<"interval">>, Healthcheck, <<"0s">>), <<"healthcheck.interval">>),
|
||||||
|
timeout_ns = parse_duration_ns(maps:get(<<"timeout">>, Healthcheck, <<"0s">>), <<"healthcheck.timeout">>),
|
||||||
|
retries = maps:get(<<"retries">>, Healthcheck, 0)
|
||||||
|
}.
|
||||||
|
|
||||||
|
-spec build_resource_limits(map()) -> undefined | message_pb:'ResourceLimits'().
|
||||||
|
build_resource_limits(Config) when is_map(Config) ->
|
||||||
|
MemoryBytes = parse_optional_size_bytes(maps:get(<<"mem_limit">>, Config, undefined), <<"mem_limit">>),
|
||||||
|
ReservationBytes = parse_optional_size_bytes(maps:get(<<"mem_reservation">>, Config, undefined), <<"mem_reservation">>),
|
||||||
|
NanoCpus = parse_optional_nano_cpus(maps:get(<<"cpus">>, Config, undefined)),
|
||||||
|
CpuShares = maps:get(<<"cpu_shares">>, Config, undefined),
|
||||||
|
case {MemoryBytes, ReservationBytes, NanoCpus, CpuShares} of
|
||||||
|
{undefined, undefined, undefined, undefined} ->
|
||||||
|
undefined;
|
||||||
|
_ ->
|
||||||
|
#'ResourceLimits'{
|
||||||
|
memory_bytes = default_uint64(MemoryBytes),
|
||||||
|
memory_reservation_bytes = default_uint64(ReservationBytes),
|
||||||
|
nano_cpus = default_uint64(NanoCpus),
|
||||||
|
cpu_shares = default_uint64(CpuShares)
|
||||||
|
}
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec default_uint64(undefined | non_neg_integer()) -> non_neg_integer().
|
||||||
|
default_uint64(undefined) ->
|
||||||
|
0;
|
||||||
|
default_uint64(Value) when is_integer(Value), Value >= 0 ->
|
||||||
|
Value.
|
||||||
|
|
||||||
|
-spec parse_optional_nano_cpus(undefined | number()) -> undefined | non_neg_integer().
|
||||||
|
parse_optional_nano_cpus(undefined) ->
|
||||||
|
undefined;
|
||||||
|
parse_optional_nano_cpus(Cpus) when is_integer(Cpus), Cpus >= 0 ->
|
||||||
|
Cpus * 1000000000;
|
||||||
|
parse_optional_nano_cpus(Cpus) when is_float(Cpus), Cpus >= 0 ->
|
||||||
|
trunc(Cpus * 1000000000).
|
||||||
|
|
||||||
|
-spec build_volume_binds([binary()]) -> [message_pb:'VolumeBind'()].
|
||||||
|
build_volume_binds(VolumeSpecs) when is_list(VolumeSpecs) ->
|
||||||
|
[build_volume_bind(VolumeSpec) || VolumeSpec <- VolumeSpecs].
|
||||||
|
|
||||||
|
-spec build_volume_bind(binary()) -> message_pb:'VolumeBind'().
|
||||||
|
build_volume_bind(VolumeSpec) when is_binary(VolumeSpec) ->
|
||||||
|
case binary:split(VolumeSpec, <<":">>, [global]) of
|
||||||
|
[HostPath, ContainerPath] when HostPath =/= <<>>, ContainerPath =/= <<>> ->
|
||||||
|
#'VolumeBind'{host_path = HostPath, container_path = ContainerPath, read_only = false};
|
||||||
|
[HostPath, ContainerPath | Modes] when HostPath =/= <<>>, ContainerPath =/= <<>> ->
|
||||||
|
#'VolumeBind'{host_path = HostPath, container_path = ContainerPath, read_only = lists:member(<<"ro">>, Modes)};
|
||||||
|
_ ->
|
||||||
|
throw({error, <<"invalid volume binding">>})
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec build_port_exposes([binary()]) -> [message_pb:'PortExpose'()].
|
||||||
|
build_port_exposes(ExposeSpecs) when is_list(ExposeSpecs) ->
|
||||||
|
[build_port_expose(ExposeSpec) || ExposeSpec <- ExposeSpecs].
|
||||||
|
|
||||||
|
-spec build_port_expose(binary()) -> message_pb:'PortExpose'().
|
||||||
|
build_port_expose(ExposeSpec) when is_binary(ExposeSpec) ->
|
||||||
|
case binary:split(ExposeSpec, <<"/">>) of
|
||||||
|
[PortBin] ->
|
||||||
|
#'PortExpose'{container_port = parse_uint32(PortBin, <<"expose">>), protocol = <<"tcp">>};
|
||||||
|
[PortBin, Protocol] ->
|
||||||
|
#'PortExpose'{container_port = parse_uint32(PortBin, <<"expose">>), protocol = Protocol}
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec build_device_mappings([binary()]) -> [message_pb:'DeviceMapping'()].
|
||||||
|
build_device_mappings(DeviceSpecs) when is_list(DeviceSpecs) ->
|
||||||
|
[build_device_mapping(DeviceSpec) || DeviceSpec <- DeviceSpecs].
|
||||||
|
|
||||||
|
-spec build_device_mapping(binary()) -> message_pb:'DeviceMapping'().
|
||||||
|
build_device_mapping(DeviceSpec) when is_binary(DeviceSpec) ->
|
||||||
|
case binary:split(DeviceSpec, <<":">>, [global]) of
|
||||||
|
[HostPath, ContainerPath] when HostPath =/= <<>>, ContainerPath =/= <<>> ->
|
||||||
|
#'DeviceMapping'{host_path = HostPath, container_path = ContainerPath, cgroup_permissions = <<"rwm">>};
|
||||||
|
[HostPath, ContainerPath, Permissions] when HostPath =/= <<>>, ContainerPath =/= <<>>, Permissions =/= <<>> ->
|
||||||
|
#'DeviceMapping'{host_path = HostPath, container_path = ContainerPath, cgroup_permissions = Permissions};
|
||||||
|
_ ->
|
||||||
|
throw({error, <<"invalid device mapping">>})
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec build_ulimits(map()) -> [message_pb:'Ulimit'()].
|
||||||
|
build_ulimits(Ulimits) when is_map(Ulimits) ->
|
||||||
|
[build_ulimit(Name, Value) || {Name, Value} <- maps:to_list(Ulimits)].
|
||||||
|
|
||||||
|
-spec build_ulimit(binary(), binary()) -> message_pb:'Ulimit'().
|
||||||
|
build_ulimit(Name, Value) when is_binary(Name), is_binary(Value) ->
|
||||||
|
case binary:split(Value, <<":">>) of
|
||||||
|
[SoftBin, HardBin] ->
|
||||||
|
#'Ulimit'{name = Name, soft = parse_uint64(SoftBin, <<"ulimits.soft">>), hard = parse_uint64(HardBin, <<"ulimits.hard">>)};
|
||||||
|
[LimitBin] ->
|
||||||
|
Limit = parse_uint64(LimitBin, <<"ulimits.limit">>),
|
||||||
|
#'Ulimit'{name = Name, soft = Limit, hard = Limit}
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec build_tmpfs_mounts([binary()]) -> [message_pb:'TmpfsMount'()].
|
||||||
|
build_tmpfs_mounts(TmpfsSpecs) when is_list(TmpfsSpecs) ->
|
||||||
|
[build_tmpfs_mount(TmpfsSpec) || TmpfsSpec <- TmpfsSpecs].
|
||||||
|
|
||||||
|
-spec build_tmpfs_mount(binary()) -> message_pb:'TmpfsMount'().
|
||||||
|
build_tmpfs_mount(TmpfsSpec) when is_binary(TmpfsSpec) ->
|
||||||
|
case binary:split(TmpfsSpec, <<":">>) of
|
||||||
|
[Path] when Path =/= <<>> ->
|
||||||
|
#'TmpfsMount'{path = Path, options = <<>>};
|
||||||
|
[Path, Options] when Path =/= <<>> ->
|
||||||
|
#'TmpfsMount'{path = Path, options = Options};
|
||||||
|
_ ->
|
||||||
|
throw({error, <<"invalid tmpfs mount">>})
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec parse_optional_size_bytes(undefined | binary(), binary()) -> undefined | non_neg_integer().
|
||||||
|
parse_optional_size_bytes(undefined, _Field) ->
|
||||||
|
undefined;
|
||||||
|
parse_optional_size_bytes(Value, Field) when is_binary(Value) ->
|
||||||
|
parse_size_bytes(Value, Field).
|
||||||
|
|
||||||
|
-spec parse_duration_ns(binary() | integer(), binary()) -> non_neg_integer().
|
||||||
|
parse_duration_ns(Value, _Field) when is_integer(Value), Value >= 0 ->
|
||||||
|
Value;
|
||||||
|
parse_duration_ns(Value, Field) when is_binary(Value) ->
|
||||||
|
parse_scaled_uint64(Value, Field, #{
|
||||||
|
<<"ns">> => 1,
|
||||||
|
<<"us">> => 1000,
|
||||||
|
<<"ms">> => 1000000,
|
||||||
|
<<"s">> => 1000000000,
|
||||||
|
<<"m">> => 60000000000,
|
||||||
|
<<"h">> => 3600000000000,
|
||||||
|
<<>> => 1000000000
|
||||||
|
}).
|
||||||
|
|
||||||
|
-spec parse_size_bytes(binary(), binary()) -> non_neg_integer().
|
||||||
|
parse_size_bytes(Value, Field) when is_binary(Value) ->
|
||||||
|
parse_scaled_uint64(Value, Field, #{
|
||||||
|
<<"b">> => 1,
|
||||||
|
<<"k">> => 1024,
|
||||||
|
<<"kb">> => 1024,
|
||||||
|
<<"ki">> => 1024,
|
||||||
|
<<"kib">> => 1024,
|
||||||
|
<<"m">> => 1048576,
|
||||||
|
<<"mb">> => 1048576,
|
||||||
|
<<"mi">> => 1048576,
|
||||||
|
<<"mib">> => 1048576,
|
||||||
|
<<"g">> => 1073741824,
|
||||||
|
<<"gb">> => 1073741824,
|
||||||
|
<<"gi">> => 1073741824,
|
||||||
|
<<"gib">> => 1073741824,
|
||||||
|
<<"t">> => 1099511627776,
|
||||||
|
<<"tb">> => 1099511627776,
|
||||||
|
<<"ti">> => 1099511627776,
|
||||||
|
<<"tib">> => 1099511627776,
|
||||||
|
<<>> => 1
|
||||||
|
}).
|
||||||
|
|
||||||
|
-spec parse_scaled_uint64(binary(), binary(), map()) -> non_neg_integer().
|
||||||
|
parse_scaled_uint64(Value0, Field, Multipliers) when is_binary(Value0), is_binary(Field), is_map(Multipliers) ->
|
||||||
|
Value = trim_binary(Value0),
|
||||||
|
LowerValue = lower_binary(Value),
|
||||||
|
{NumberBin, Unit} = split_numeric_suffix(LowerValue),
|
||||||
|
case maps:get(Unit, Multipliers, undefined) of
|
||||||
|
undefined ->
|
||||||
|
throw({error, <<"invalid value for ", Field/binary, ": ", Value0/binary>>});
|
||||||
|
Multiplier ->
|
||||||
|
trunc(parse_decimal(NumberBin, Field) * Multiplier)
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec parse_uint32(binary(), binary()) -> non_neg_integer().
|
||||||
|
parse_uint32(Value, Field) when is_binary(Value), is_binary(Field) ->
|
||||||
|
Parsed = parse_uint64(Value, Field),
|
||||||
|
case Parsed =< 16#FFFFFFFF of
|
||||||
|
true ->
|
||||||
|
Parsed;
|
||||||
|
false ->
|
||||||
|
throw({error, <<"value overflow for ", Field/binary>>})
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec parse_uint64(binary(), binary()) -> non_neg_integer().
|
||||||
|
parse_uint64(Value0, Field) when is_binary(Value0), is_binary(Field) ->
|
||||||
|
Value = trim_binary(Value0),
|
||||||
|
case catch binary_to_integer(Value) of
|
||||||
|
Parsed when is_integer(Parsed), Parsed >= 0 ->
|
||||||
|
Parsed;
|
||||||
|
_ ->
|
||||||
|
throw({error, <<"invalid unsigned integer for ", Field/binary, ": ", Value0/binary>>})
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec parse_decimal(binary(), binary()) -> float().
|
||||||
|
parse_decimal(Value, Field) when is_binary(Value), is_binary(Field) ->
|
||||||
|
case catch binary_to_integer(Value) of
|
||||||
|
ParsedInt when is_integer(ParsedInt), ParsedInt >= 0 ->
|
||||||
|
float(ParsedInt);
|
||||||
|
_ ->
|
||||||
|
case catch binary_to_float(Value) of
|
||||||
|
ParsedFloat when is_float(ParsedFloat), ParsedFloat >= 0 ->
|
||||||
|
ParsedFloat;
|
||||||
|
_ ->
|
||||||
|
throw({error, <<"invalid number for ", Field/binary, ": ", Value/binary>>})
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec split_numeric_suffix(binary()) -> {binary(), binary()}.
|
||||||
|
split_numeric_suffix(Value) when is_binary(Value) ->
|
||||||
|
split_numeric_suffix(Value, <<>>).
|
||||||
|
|
||||||
|
-spec split_numeric_suffix(binary(), binary()) -> {binary(), binary()}.
|
||||||
|
split_numeric_suffix(<<Char, Rest/binary>>, Acc)
|
||||||
|
when (Char >= $0 andalso Char =< $9) orelse Char =:= $. ->
|
||||||
|
split_numeric_suffix(Rest, <<Acc/binary, Char>>);
|
||||||
|
split_numeric_suffix(Rest, <<>>) ->
|
||||||
|
throw({error, <<"invalid numeric value: ", Rest/binary>>});
|
||||||
|
split_numeric_suffix(Rest, Acc) ->
|
||||||
|
{Acc, Rest}.
|
||||||
|
|
||||||
|
-spec trim_binary(binary()) -> binary().
|
||||||
|
trim_binary(Value) when is_binary(Value) ->
|
||||||
|
Trimmed = string:trim(binary_to_list(Value)),
|
||||||
|
list_to_binary(Trimmed).
|
||||||
|
|
||||||
|
-spec lower_binary(binary()) -> binary().
|
||||||
|
lower_binary(Value) when is_binary(Value) ->
|
||||||
|
list_to_binary(string:lowercase(binary_to_list(Value))).
|
||||||
@ -95,18 +95,15 @@ attach_channel(Pid, ChannelPid) when is_pid(Pid), is_pid(ChannelPid) ->
|
|||||||
|
|
||||||
-spec get_containers(Pid :: pid()) -> {ok, Ref :: reference()} | {error, Reason :: any()}.
|
-spec get_containers(Pid :: pid()) -> {ok, Ref :: reference()} | {error, Reason :: any()}.
|
||||||
get_containers(Pid) when is_pid(Pid) ->
|
get_containers(Pid) when is_pid(Pid) ->
|
||||||
container_call(Pid, #'ContainerRequest'{action = {list, #'ContainerRequest.List'{all = true}}}).
|
container_call(Pid, iot_container_request_builder:list_request()).
|
||||||
|
|
||||||
-spec config_container(Pid :: pid(), ContainerName :: binary(), ConfigJson :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}.
|
-spec config_container(Pid :: pid(), ContainerName :: binary(), ConfigJson :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}.
|
||||||
config_container(Pid, ContainerName, ConfigJson) when is_pid(Pid), is_binary(ContainerName), is_binary(ConfigJson) ->
|
config_container(Pid, ContainerName, ConfigJson) when is_pid(Pid), is_binary(ContainerName), is_binary(ConfigJson) ->
|
||||||
container_call(Pid, #'ContainerRequest'{action = {config, #'ContainerRequest.Config'{
|
container_call(Pid, iot_container_request_builder:config_request(ContainerName, ConfigJson)).
|
||||||
target = container_ref(ContainerName),
|
|
||||||
config = ConfigJson
|
|
||||||
}}}).
|
|
||||||
|
|
||||||
-spec deploy_container(Pid :: pid(), TaskId :: integer(), Config :: map()) -> {ok, Ref :: reference()} | {error, Reason :: any()}.
|
-spec deploy_container(Pid :: pid(), TaskId :: integer(), Config :: map()) -> {ok, Ref :: reference()} | {error, Reason :: any()}.
|
||||||
deploy_container(Pid, TaskId, Config) when is_pid(Pid), is_integer(TaskId), is_map(Config) ->
|
deploy_container(Pid, TaskId, Config) when is_pid(Pid), is_integer(TaskId), is_map(Config) ->
|
||||||
case build_deploy_request(TaskId, Config) of
|
case iot_container_request_builder:deploy_request(TaskId, Config) of
|
||||||
{ok, Request} ->
|
{ok, Request} ->
|
||||||
container_call(Pid, Request);
|
container_call(Pid, Request);
|
||||||
{error, Reason} ->
|
{error, Reason} ->
|
||||||
@ -115,29 +112,19 @@ deploy_container(Pid, TaskId, Config) when is_pid(Pid), is_integer(TaskId), is_m
|
|||||||
|
|
||||||
-spec start_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}.
|
-spec start_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}.
|
||||||
start_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) ->
|
start_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) ->
|
||||||
container_call(Pid, #'ContainerRequest'{action = {start, #'ContainerRequest.Start'{target = container_ref(ContainerName)}}}).
|
container_call(Pid, iot_container_request_builder:start_request(ContainerName)).
|
||||||
|
|
||||||
-spec stop_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}.
|
-spec stop_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}.
|
||||||
stop_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) ->
|
stop_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) ->
|
||||||
container_call(Pid, #'ContainerRequest'{action = {stop, #'ContainerRequest.Stop'{
|
container_call(Pid, iot_container_request_builder:stop_request(ContainerName)).
|
||||||
target = container_ref(ContainerName),
|
|
||||||
timeout_seconds = 0
|
|
||||||
}}}).
|
|
||||||
|
|
||||||
-spec kill_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}.
|
-spec kill_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}.
|
||||||
kill_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) ->
|
kill_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) ->
|
||||||
container_call(Pid, #'ContainerRequest'{action = {kill, #'ContainerRequest.Kill'{
|
container_call(Pid, iot_container_request_builder:kill_request(ContainerName)).
|
||||||
target = container_ref(ContainerName),
|
|
||||||
signal = <<>>
|
|
||||||
}}}).
|
|
||||||
|
|
||||||
-spec remove_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}.
|
-spec remove_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}.
|
||||||
remove_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) ->
|
remove_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) ->
|
||||||
container_call(Pid, #'ContainerRequest'{action = {remove, #'ContainerRequest.Remove'{
|
container_call(Pid, iot_container_request_builder:remove_request(ContainerName)).
|
||||||
target = container_ref(ContainerName),
|
|
||||||
force = false,
|
|
||||||
remove_volumes = false
|
|
||||||
}}}).
|
|
||||||
|
|
||||||
-spec await_reply(Pid :: pid(), Ref :: reference(), Timeout :: integer()) ->
|
-spec await_reply(Pid :: pid(), Ref :: reference(), Timeout :: integer()) ->
|
||||||
{ok, Result :: binary()} | {error, Code :: integer(), Reason :: binary()}.
|
{ok, Result :: binary()} | {error, Code :: integer(), Reason :: binary()}.
|
||||||
@ -406,315 +393,6 @@ code_change(_OldVsn, StateName, State = #state{}, _Extra) ->
|
|||||||
container_call(Pid, Request) when is_pid(Pid), is_record(Request, 'ContainerRequest') ->
|
container_call(Pid, Request) when is_pid(Pid), is_record(Request, 'ContainerRequest') ->
|
||||||
gen_statem:call(Pid, {container_call, self(), Request}).
|
gen_statem:call(Pid, {container_call, self(), Request}).
|
||||||
|
|
||||||
-spec container_ref(ContainerName :: binary()) -> message_pb:'ContainerRef'().
|
|
||||||
container_ref(ContainerName) when is_binary(ContainerName) ->
|
|
||||||
#'ContainerRef'{name = ContainerName}.
|
|
||||||
|
|
||||||
-spec build_deploy_request(TaskId :: integer(), Config :: map()) ->
|
|
||||||
{ok, message_pb:'ContainerRequest'()} | {error, binary()}.
|
|
||||||
build_deploy_request(TaskId, Config) when is_integer(TaskId), is_map(Config), TaskId >= 0 ->
|
|
||||||
try
|
|
||||||
ensure_supported_deploy_config(Config),
|
|
||||||
Params = build_container_deploy_params(Config),
|
|
||||||
{ok, #'ContainerRequest'{action = {deploy, #'ContainerRequest.Deploy'{task_id = TaskId, params = Params}}}}
|
|
||||||
catch
|
|
||||||
throw:{error, Reason} ->
|
|
||||||
{error, Reason}
|
|
||||||
end.
|
|
||||||
|
|
||||||
-spec ensure_supported_deploy_config(Config :: map()) -> ok.
|
|
||||||
ensure_supported_deploy_config(Config) when is_map(Config) ->
|
|
||||||
UnsupportedKeys = [Key || Key <- [<<"ports">>], maps:is_key(Key, Config)],
|
|
||||||
case UnsupportedKeys of
|
|
||||||
[] ->
|
|
||||||
ok;
|
|
||||||
_ ->
|
|
||||||
Unsupported = iolist_to_binary(lists:join(<<", ">>, UnsupportedKeys)),
|
|
||||||
throw({error, <<"unsupported container config keys: ", Unsupported/binary>>})
|
|
||||||
end.
|
|
||||||
|
|
||||||
-spec build_container_deploy_params(Config :: map()) -> message_pb:'ContainerDeployParams'().
|
|
||||||
build_container_deploy_params(Config) when is_map(Config) ->
|
|
||||||
ContainerName = maps:get(<<"container_name">>, Config),
|
|
||||||
ContainerDir = maps:get(<<"container_dir">>, Config, <<>>),
|
|
||||||
Spec = build_container_spec(Config),
|
|
||||||
#'ContainerDeployParams'{
|
|
||||||
container_name = ContainerName,
|
|
||||||
container_dir = ContainerDir,
|
|
||||||
spec = Spec
|
|
||||||
}.
|
|
||||||
|
|
||||||
-spec build_container_spec(Config :: map()) -> message_pb:'ContainerSpec'().
|
|
||||||
build_container_spec(Config) when is_map(Config) ->
|
|
||||||
Volumes = build_volume_binds(maps:get(<<"volumes">>, Config, [])),
|
|
||||||
Expose = build_port_exposes(maps:get(<<"expose">>, Config, [])),
|
|
||||||
Restart = build_restart_policy(maps:get(<<"restart">>, Config)),
|
|
||||||
Devices = build_device_mappings(maps:get(<<"devices">>, Config, [])),
|
|
||||||
Resources = build_resource_limits(Config),
|
|
||||||
Ulimits = build_ulimits(maps:get(<<"ulimits">>, Config, #{})),
|
|
||||||
Tmpfs = build_tmpfs_mounts(maps:get(<<"tmpfs">>, Config, [])),
|
|
||||||
Healthcheck = build_healthcheck(maps:get(<<"healthcheck">>, Config, undefined)),
|
|
||||||
#'ContainerSpec'{
|
|
||||||
image = maps:get(<<"image">>, Config),
|
|
||||||
command = maps:get(<<"command">>, Config),
|
|
||||||
entrypoint = maps:get(<<"entrypoint">>, Config, []),
|
|
||||||
env = maps:get(<<"envs">>, Config, []),
|
|
||||||
labels = maps:to_list(maps:get(<<"labels">>, Config, #{})),
|
|
||||||
volumes = Volumes,
|
|
||||||
user = maps:get(<<"user">>, Config, <<>>),
|
|
||||||
working_dir = maps:get(<<"working_dir">>, Config, <<>>),
|
|
||||||
hostname = maps:get(<<"hostname">>, Config, <<>>),
|
|
||||||
expose = Expose,
|
|
||||||
networks = maps:get(<<"networks">>, Config, []),
|
|
||||||
network_mode = maps:get(<<"network_mode">>, Config, <<>>),
|
|
||||||
healthcheck = Healthcheck,
|
|
||||||
restart = Restart,
|
|
||||||
privileged = maps:get(<<"privileged">>, Config, false),
|
|
||||||
cap_add = maps:get(<<"cap_add">>, Config, []),
|
|
||||||
cap_drop = maps:get(<<"cap_drop">>, Config, []),
|
|
||||||
devices = Devices,
|
|
||||||
resources = Resources,
|
|
||||||
ulimits = Ulimits,
|
|
||||||
tmpfs = Tmpfs,
|
|
||||||
sysctls = maps:to_list(maps:get(<<"sysctls">>, Config, #{})),
|
|
||||||
extra_hosts = maps:get(<<"extra_hosts">>, Config, [])
|
|
||||||
}.
|
|
||||||
|
|
||||||
-spec build_restart_policy(binary()) -> message_pb:'RestartPolicy'().
|
|
||||||
build_restart_policy(Restart0) when is_binary(Restart0) ->
|
|
||||||
case binary:split(Restart0, <<":">>) of
|
|
||||||
[Name, RetryCountBin] ->
|
|
||||||
#'RestartPolicy'{name = Name, maximum_retry_count = parse_uint32(RetryCountBin, <<"restart">>)};
|
|
||||||
[Name] ->
|
|
||||||
#'RestartPolicy'{name = Name, maximum_retry_count = 0}
|
|
||||||
end.
|
|
||||||
|
|
||||||
-spec build_healthcheck(undefined | map()) -> undefined | message_pb:'Healthcheck'().
|
|
||||||
build_healthcheck(undefined) ->
|
|
||||||
undefined;
|
|
||||||
build_healthcheck(Healthcheck) when is_map(Healthcheck) ->
|
|
||||||
#'Healthcheck'{
|
|
||||||
test = maps:get(<<"test">>, Healthcheck, []),
|
|
||||||
interval_ns = parse_duration_ns(maps:get(<<"interval">>, Healthcheck, <<"0s">>), <<"healthcheck.interval">>),
|
|
||||||
timeout_ns = parse_duration_ns(maps:get(<<"timeout">>, Healthcheck, <<"0s">>), <<"healthcheck.timeout">>),
|
|
||||||
retries = maps:get(<<"retries">>, Healthcheck, 0)
|
|
||||||
}.
|
|
||||||
|
|
||||||
-spec build_resource_limits(map()) -> undefined | message_pb:'ResourceLimits'().
|
|
||||||
build_resource_limits(Config) when is_map(Config) ->
|
|
||||||
MemoryBytes = parse_optional_size_bytes(maps:get(<<"mem_limit">>, Config, undefined), <<"mem_limit">>),
|
|
||||||
ReservationBytes = parse_optional_size_bytes(maps:get(<<"mem_reservation">>, Config, undefined), <<"mem_reservation">>),
|
|
||||||
NanoCpus = parse_optional_nano_cpus(maps:get(<<"cpus">>, Config, undefined)),
|
|
||||||
CpuShares = maps:get(<<"cpu_shares">>, Config, undefined),
|
|
||||||
case {MemoryBytes, ReservationBytes, NanoCpus, CpuShares} of
|
|
||||||
{undefined, undefined, undefined, undefined} ->
|
|
||||||
undefined;
|
|
||||||
_ ->
|
|
||||||
#'ResourceLimits'{
|
|
||||||
memory_bytes = default_uint64(MemoryBytes),
|
|
||||||
memory_reservation_bytes = default_uint64(ReservationBytes),
|
|
||||||
nano_cpus = default_uint64(NanoCpus),
|
|
||||||
cpu_shares = default_uint64(CpuShares)
|
|
||||||
}
|
|
||||||
end.
|
|
||||||
|
|
||||||
-spec default_uint64(undefined | non_neg_integer()) -> non_neg_integer().
|
|
||||||
default_uint64(undefined) ->
|
|
||||||
0;
|
|
||||||
default_uint64(Value) when is_integer(Value), Value >= 0 ->
|
|
||||||
Value.
|
|
||||||
|
|
||||||
-spec parse_optional_nano_cpus(undefined | number()) -> undefined | non_neg_integer().
|
|
||||||
parse_optional_nano_cpus(undefined) ->
|
|
||||||
undefined;
|
|
||||||
parse_optional_nano_cpus(Cpus) when is_integer(Cpus), Cpus >= 0 ->
|
|
||||||
Cpus * 1000000000;
|
|
||||||
parse_optional_nano_cpus(Cpus) when is_float(Cpus), Cpus >= 0 ->
|
|
||||||
trunc(Cpus * 1000000000).
|
|
||||||
|
|
||||||
-spec build_volume_binds([binary()]) -> [message_pb:'VolumeBind'()].
|
|
||||||
build_volume_binds(VolumeSpecs) when is_list(VolumeSpecs) ->
|
|
||||||
[build_volume_bind(VolumeSpec) || VolumeSpec <- VolumeSpecs].
|
|
||||||
|
|
||||||
-spec build_volume_bind(binary()) -> message_pb:'VolumeBind'().
|
|
||||||
build_volume_bind(VolumeSpec) when is_binary(VolumeSpec) ->
|
|
||||||
case binary:split(VolumeSpec, <<":">>, [global]) of
|
|
||||||
[HostPath, ContainerPath] when HostPath =/= <<>>, ContainerPath =/= <<>> ->
|
|
||||||
#'VolumeBind'{host_path = HostPath, container_path = ContainerPath, read_only = false};
|
|
||||||
[HostPath, ContainerPath | Modes] when HostPath =/= <<>>, ContainerPath =/= <<>> ->
|
|
||||||
#'VolumeBind'{host_path = HostPath, container_path = ContainerPath, read_only = lists:member(<<"ro">>, Modes)};
|
|
||||||
_ ->
|
|
||||||
throw({error, <<"invalid volume binding">>})
|
|
||||||
end.
|
|
||||||
|
|
||||||
-spec build_port_exposes([binary()]) -> [message_pb:'PortExpose'()].
|
|
||||||
build_port_exposes(ExposeSpecs) when is_list(ExposeSpecs) ->
|
|
||||||
[build_port_expose(ExposeSpec) || ExposeSpec <- ExposeSpecs].
|
|
||||||
|
|
||||||
-spec build_port_expose(binary()) -> message_pb:'PortExpose'().
|
|
||||||
build_port_expose(ExposeSpec) when is_binary(ExposeSpec) ->
|
|
||||||
case binary:split(ExposeSpec, <<"/">>) of
|
|
||||||
[PortBin] ->
|
|
||||||
#'PortExpose'{container_port = parse_uint32(PortBin, <<"expose">>), protocol = <<"tcp">>};
|
|
||||||
[PortBin, Protocol] ->
|
|
||||||
#'PortExpose'{container_port = parse_uint32(PortBin, <<"expose">>), protocol = Protocol}
|
|
||||||
end.
|
|
||||||
|
|
||||||
-spec build_device_mappings([binary()]) -> [message_pb:'DeviceMapping'()].
|
|
||||||
build_device_mappings(DeviceSpecs) when is_list(DeviceSpecs) ->
|
|
||||||
[build_device_mapping(DeviceSpec) || DeviceSpec <- DeviceSpecs].
|
|
||||||
|
|
||||||
-spec build_device_mapping(binary()) -> message_pb:'DeviceMapping'().
|
|
||||||
build_device_mapping(DeviceSpec) when is_binary(DeviceSpec) ->
|
|
||||||
case binary:split(DeviceSpec, <<":">>, [global]) of
|
|
||||||
[HostPath, ContainerPath] when HostPath =/= <<>>, ContainerPath =/= <<>> ->
|
|
||||||
#'DeviceMapping'{host_path = HostPath, container_path = ContainerPath, cgroup_permissions = <<"rwm">>};
|
|
||||||
[HostPath, ContainerPath, Permissions] when HostPath =/= <<>>, ContainerPath =/= <<>>, Permissions =/= <<>> ->
|
|
||||||
#'DeviceMapping'{host_path = HostPath, container_path = ContainerPath, cgroup_permissions = Permissions};
|
|
||||||
_ ->
|
|
||||||
throw({error, <<"invalid device mapping">>})
|
|
||||||
end.
|
|
||||||
|
|
||||||
-spec build_ulimits(map()) -> [message_pb:'Ulimit'()].
|
|
||||||
build_ulimits(Ulimits) when is_map(Ulimits) ->
|
|
||||||
[build_ulimit(Name, Value) || {Name, Value} <- maps:to_list(Ulimits)].
|
|
||||||
|
|
||||||
-spec build_ulimit(binary(), binary()) -> message_pb:'Ulimit'().
|
|
||||||
build_ulimit(Name, Value) when is_binary(Name), is_binary(Value) ->
|
|
||||||
case binary:split(Value, <<":">>) of
|
|
||||||
[SoftBin, HardBin] ->
|
|
||||||
#'Ulimit'{name = Name, soft = parse_uint64(SoftBin, <<"ulimits.soft">>), hard = parse_uint64(HardBin, <<"ulimits.hard">>)};
|
|
||||||
[LimitBin] ->
|
|
||||||
Limit = parse_uint64(LimitBin, <<"ulimits.limit">>),
|
|
||||||
#'Ulimit'{name = Name, soft = Limit, hard = Limit}
|
|
||||||
end.
|
|
||||||
|
|
||||||
-spec build_tmpfs_mounts([binary()]) -> [message_pb:'TmpfsMount'()].
|
|
||||||
build_tmpfs_mounts(TmpfsSpecs) when is_list(TmpfsSpecs) ->
|
|
||||||
[build_tmpfs_mount(TmpfsSpec) || TmpfsSpec <- TmpfsSpecs].
|
|
||||||
|
|
||||||
-spec build_tmpfs_mount(binary()) -> message_pb:'TmpfsMount'().
|
|
||||||
build_tmpfs_mount(TmpfsSpec) when is_binary(TmpfsSpec) ->
|
|
||||||
case binary:split(TmpfsSpec, <<":">>) of
|
|
||||||
[Path] when Path =/= <<>> ->
|
|
||||||
#'TmpfsMount'{path = Path, options = <<>>};
|
|
||||||
[Path, Options] when Path =/= <<>> ->
|
|
||||||
#'TmpfsMount'{path = Path, options = Options};
|
|
||||||
_ ->
|
|
||||||
throw({error, <<"invalid tmpfs mount">>})
|
|
||||||
end.
|
|
||||||
|
|
||||||
-spec parse_optional_size_bytes(undefined | binary(), binary()) -> undefined | non_neg_integer().
|
|
||||||
parse_optional_size_bytes(undefined, _Field) ->
|
|
||||||
undefined;
|
|
||||||
parse_optional_size_bytes(Value, Field) when is_binary(Value) ->
|
|
||||||
parse_size_bytes(Value, Field).
|
|
||||||
|
|
||||||
-spec parse_duration_ns(binary() | integer(), binary()) -> non_neg_integer().
|
|
||||||
parse_duration_ns(Value, _Field) when is_integer(Value), Value >= 0 ->
|
|
||||||
Value;
|
|
||||||
parse_duration_ns(Value, Field) when is_binary(Value) ->
|
|
||||||
parse_scaled_uint64(Value, Field, #{
|
|
||||||
<<"ns">> => 1,
|
|
||||||
<<"us">> => 1000,
|
|
||||||
<<"ms">> => 1000000,
|
|
||||||
<<"s">> => 1000000000,
|
|
||||||
<<"m">> => 60000000000,
|
|
||||||
<<"h">> => 3600000000000,
|
|
||||||
<<>> => 1000000000
|
|
||||||
}).
|
|
||||||
|
|
||||||
-spec parse_size_bytes(binary(), binary()) -> non_neg_integer().
|
|
||||||
parse_size_bytes(Value, Field) when is_binary(Value) ->
|
|
||||||
parse_scaled_uint64(Value, Field, #{
|
|
||||||
<<"b">> => 1,
|
|
||||||
<<"k">> => 1024,
|
|
||||||
<<"kb">> => 1024,
|
|
||||||
<<"ki">> => 1024,
|
|
||||||
<<"kib">> => 1024,
|
|
||||||
<<"m">> => 1048576,
|
|
||||||
<<"mb">> => 1048576,
|
|
||||||
<<"mi">> => 1048576,
|
|
||||||
<<"mib">> => 1048576,
|
|
||||||
<<"g">> => 1073741824,
|
|
||||||
<<"gb">> => 1073741824,
|
|
||||||
<<"gi">> => 1073741824,
|
|
||||||
<<"gib">> => 1073741824,
|
|
||||||
<<"t">> => 1099511627776,
|
|
||||||
<<"tb">> => 1099511627776,
|
|
||||||
<<"ti">> => 1099511627776,
|
|
||||||
<<"tib">> => 1099511627776,
|
|
||||||
<<>> => 1
|
|
||||||
}).
|
|
||||||
|
|
||||||
-spec parse_scaled_uint64(binary(), binary(), map()) -> non_neg_integer().
|
|
||||||
parse_scaled_uint64(Value0, Field, Multipliers) when is_binary(Value0), is_binary(Field), is_map(Multipliers) ->
|
|
||||||
Value = trim_binary(Value0),
|
|
||||||
LowerValue = lower_binary(Value),
|
|
||||||
{NumberBin, Unit} = split_numeric_suffix(LowerValue),
|
|
||||||
case maps:get(Unit, Multipliers, undefined) of
|
|
||||||
undefined ->
|
|
||||||
throw({error, <<"invalid value for ", Field/binary, ": ", Value0/binary>>});
|
|
||||||
Multiplier ->
|
|
||||||
trunc(parse_decimal(NumberBin, Field) * Multiplier)
|
|
||||||
end.
|
|
||||||
|
|
||||||
-spec parse_uint32(binary(), binary()) -> non_neg_integer().
|
|
||||||
parse_uint32(Value, Field) when is_binary(Value), is_binary(Field) ->
|
|
||||||
Parsed = parse_uint64(Value, Field),
|
|
||||||
case Parsed =< 16#FFFFFFFF of
|
|
||||||
true ->
|
|
||||||
Parsed;
|
|
||||||
false ->
|
|
||||||
throw({error, <<"value overflow for ", Field/binary>>})
|
|
||||||
end.
|
|
||||||
|
|
||||||
-spec parse_uint64(binary(), binary()) -> non_neg_integer().
|
|
||||||
parse_uint64(Value0, Field) when is_binary(Value0), is_binary(Field) ->
|
|
||||||
Value = trim_binary(Value0),
|
|
||||||
case catch binary_to_integer(Value) of
|
|
||||||
Parsed when is_integer(Parsed), Parsed >= 0 ->
|
|
||||||
Parsed;
|
|
||||||
_ ->
|
|
||||||
throw({error, <<"invalid unsigned integer for ", Field/binary, ": ", Value0/binary>>})
|
|
||||||
end.
|
|
||||||
|
|
||||||
-spec parse_decimal(binary(), binary()) -> float().
|
|
||||||
parse_decimal(Value, Field) when is_binary(Value), is_binary(Field) ->
|
|
||||||
case catch binary_to_integer(Value) of
|
|
||||||
ParsedInt when is_integer(ParsedInt), ParsedInt >= 0 ->
|
|
||||||
float(ParsedInt);
|
|
||||||
_ ->
|
|
||||||
case catch binary_to_float(Value) of
|
|
||||||
ParsedFloat when is_float(ParsedFloat), ParsedFloat >= 0 ->
|
|
||||||
ParsedFloat;
|
|
||||||
_ ->
|
|
||||||
throw({error, <<"invalid number for ", Field/binary, ": ", Value/binary>>})
|
|
||||||
end
|
|
||||||
end.
|
|
||||||
|
|
||||||
-spec split_numeric_suffix(binary()) -> {binary(), binary()}.
|
|
||||||
split_numeric_suffix(Value) when is_binary(Value) ->
|
|
||||||
split_numeric_suffix(Value, <<>>).
|
|
||||||
|
|
||||||
-spec split_numeric_suffix(binary(), binary()) -> {binary(), binary()}.
|
|
||||||
split_numeric_suffix(<<Char, Rest/binary>>, Acc)
|
|
||||||
when (Char >= $0 andalso Char =< $9) orelse Char =:= $. ->
|
|
||||||
split_numeric_suffix(Rest, <<Acc/binary, Char>>);
|
|
||||||
split_numeric_suffix(Rest, <<>>) ->
|
|
||||||
throw({error, <<"invalid numeric value: ", Rest/binary>>});
|
|
||||||
split_numeric_suffix(Rest, Acc) ->
|
|
||||||
{Acc, Rest}.
|
|
||||||
|
|
||||||
-spec trim_binary(binary()) -> binary().
|
|
||||||
trim_binary(Value) when is_binary(Value) ->
|
|
||||||
Trimmed = string:trim(binary_to_list(Value)),
|
|
||||||
list_to_binary(Trimmed).
|
|
||||||
|
|
||||||
-spec lower_binary(binary()) -> binary().
|
|
||||||
lower_binary(Value) when is_binary(Value) ->
|
|
||||||
list_to_binary(string:lowercase(binary_to_list(Value))).
|
|
||||||
|
|
||||||
-spec get_route_key(binary()) -> binary().
|
-spec get_route_key(binary()) -> binary().
|
||||||
get_route_key(<<"">>) ->
|
get_route_key(<<"">>) ->
|
||||||
<<"/">>;
|
<<"/">>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user