From 251acb9b17829a76dff8cc7de5a62abd6ff5c4e9 Mon Sep 17 00:00:00 2001 From: anlicheng <244108715@qq.com> Date: Sun, 19 Apr 2026 15:01:41 +0800 Subject: [PATCH] fix --- docs/container_deploy_request.md | 519 +++++++++++++++++++++ src/host/iot_container_request_builder.erl | 358 ++++++++++++++ src/host/iot_host.erl | 336 +------------ 3 files changed, 884 insertions(+), 329 deletions(-) create mode 100644 docs/container_deploy_request.md create mode 100644 src/host/iot_container_request_builder.erl diff --git a/docs/container_deploy_request.md b/docs/container_deploy_request.md new file mode 100644 index 0000000..e71f846 --- /dev/null +++ b/docs/container_deploy_request.md @@ -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` | `ContainerSpec.labels` | 转成 key/value 列表 | +| `sysctls` | `map` | `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` +- `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` +- `sysctls: map` +- `tmpfs: string[]` +- `extra_hosts: string[]` +- `healthcheck: map` + +## 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"` 这类字符串解析进去。这样语义才完整。 diff --git a/src/host/iot_container_request_builder.erl b/src/host/iot_container_request_builder.erl new file mode 100644 index 0000000..ae47f53 --- /dev/null +++ b/src/host/iot_container_request_builder.erl @@ -0,0 +1,358 @@ +%%%------------------------------------------------------------------- +%%% @author +%%% @copyright (C) 2026, +%%% @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(<>, Acc) + when (Char >= $0 andalso Char =< $9) orelse Char =:= $. -> + split_numeric_suffix(Rest, <>); +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))). diff --git a/src/host/iot_host.erl b/src/host/iot_host.erl index f8f8495..a923dfe 100644 --- a/src/host/iot_host.erl +++ b/src/host/iot_host.erl @@ -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()}. 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()}. config_container(Pid, ContainerName, ConfigJson) when is_pid(Pid), is_binary(ContainerName), is_binary(ConfigJson) -> - container_call(Pid, #'ContainerRequest'{action = {config, #'ContainerRequest.Config'{ - target = container_ref(ContainerName), - config = ConfigJson - }}}). + container_call(Pid, iot_container_request_builder:config_request(ContainerName, ConfigJson)). -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) -> - case build_deploy_request(TaskId, Config) of + case iot_container_request_builder:deploy_request(TaskId, Config) of {ok, Request} -> container_call(Pid, Request); {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()}. 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()}. stop_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) -> - container_call(Pid, #'ContainerRequest'{action = {stop, #'ContainerRequest.Stop'{ - target = container_ref(ContainerName), - timeout_seconds = 0 - }}}). + container_call(Pid, iot_container_request_builder:stop_request(ContainerName)). -spec kill_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}. kill_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) -> - container_call(Pid, #'ContainerRequest'{action = {kill, #'ContainerRequest.Kill'{ - target = container_ref(ContainerName), - signal = <<>> - }}}). + container_call(Pid, iot_container_request_builder:kill_request(ContainerName)). -spec remove_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}. remove_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) -> - container_call(Pid, #'ContainerRequest'{action = {remove, #'ContainerRequest.Remove'{ - target = container_ref(ContainerName), - force = false, - remove_volumes = false - }}}). + container_call(Pid, iot_container_request_builder:remove_request(ContainerName)). -spec await_reply(Pid :: pid(), Ref :: reference(), Timeout :: integer()) -> {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') -> 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(<>, Acc) - when (Char >= $0 andalso Char =< $9) orelse Char =:= $. -> - split_numeric_suffix(Rest, <>); -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(). get_route_key(<<"">>) -> <<"/">>;