diff --git a/hawkbit-compose/.RUNBOOK.md.swp b/hawkbit-compose/.RUNBOOK.md.swp new file mode 100644 index 000000000..18658110a Binary files /dev/null and b/hawkbit-compose/.RUNBOOK.md.swp differ diff --git a/hawkbit-compose/CURL-API.md b/hawkbit-compose/CURL-API.md new file mode 100644 index 000000000..a745e5950 --- /dev/null +++ b/hawkbit-compose/CURL-API.md @@ -0,0 +1,746 @@ +# Hawkbit DDI API — curl 完整操作手册 + +基于实际验证的 Hawkbit DDI API v1 交互流程,所有命令均已跑通。 + +## 环境信息 + +``` +Hawkbit Server: http://192.168.142.128:9090 +Tenant: DEFAULT +Auth: GatewayToken my-gateway-key (DDI 接口) + admin:admin (Management 接口) +Controller ID: test-curl-001 +DistributionSet: id=1 (r3576test:V1.0.0.0) +``` + +## 目录 + +1. [认证配置](#1-认证配置) +2. [自动注册与轮询](#2-自动注册与轮询) +3. [分配 Distribution Set](#3-分配-distribution-set) +4. [拉取部署详情](#4-拉取部署详情) +5. [上报设备属性](#5-上报设备属性) +6. [下载 artifact](#6-下载-artifact) +7. [SHA256 文件校验](#7-sha256-文件校验) +8. [上报执行状态](#8-上报执行状态) +9. [取消任务](#9-取消任务) +10. [确认任务流程](#10-确认任务流程) +11. [查询验证](#11-查询验证) +12. [完整流程速查](#12-完整流程速查) + +--- + +## 1. 认证配置 + +### 1.1 开启 GatewayToken 认证(服务端,一次性) + +```bash +# 设置 token key +curl -u admin:admin -X PUT \ + -H "Content-Type: application/json" \ + "http://192.168.142.128:9090/rest/v1/system/configs/authentication.gatewaytoken.key" \ + -d '{"value": "my-gateway-key"}' + +# 启用 gateway token 认证 +curl -u admin:admin -X PUT \ + -H "Content-Type: application/json" \ + "http://192.168.142.128:9090/rest/v1/system/configs/authentication.gatewaytoken.enabled" \ + -d '{"value": true}' +``` + +### 1.2 开启 TargetToken 认证(服务端,一次性) + +```bash +curl -u admin:admin -X PUT \ + -H "Content-Type: application/json" \ + "http://192.168.142.128:9090/rest/v1/system/configs/authentication.targettoken.enabled" \ + -d '{"value": true}' +``` + +### 1.3 查看当前所有认证配置 + +```bash +curl -s -u admin:admin \ + "http://192.168.142.128:9090/rest/v1/system/configs" \ + | python3 -m json.tool | grep -E "authentication\.(gateway|target)" +``` + +### 1.4 认证方式对比 + +``` +DDI 请求头: + GatewayToken → Authorization: GatewayToken my-gateway-key + TargetToken → Authorization: TargetToken <设备securityToken> + +Management API 请求头: + Basic Auth → -u admin:admin +``` + +--- + +## 2. 自动注册与轮询 + +### 2.1 首次轮询(自动注册) + +``` +GET /{tenant}/controller/v1/{controllerId} +``` + +设备 SN 在 Hawkbit 中不存在时,服务端自动调用 `findOrRegisterTargetIfItDoesNotExist()` 创建 target。 + +```bash +curl -v \ + -H "Authorization: GatewayToken my-gateway-key" \ + -H "Accept: application/json" \ + "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001" +``` + +**响应(无待处理 action):** + +```json +{ + "config": {"polling": {"sleep": "00:05:00"}}, + "_links": { + "configData": { + "href": "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/configData" + } + } +} +``` + +**响应(有待处理 action):** + +```json +{ + "config": {"polling": {"sleep": "00:05:00"}}, + "_links": { + "deploymentBase": { + "href": "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/deploymentBase/15?c=424529173" + }, + "configData": { + "href": "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/configData" + } + } +} +``` + +### 2.2 响应中的 HAL 链接含义 + +| 链接 rel | 含义 | 下一步动作 | +|----------|------|----------| +| `deploymentBase` | 有待执行的部署任务 | 拉取部署详情 → 下载 → 安装 | +| `cancelAction` | 有取消指令 | 确认取消 | +| `confirmationBase` | 有需要确认的任务 | 发送确认 | +| `configData` | 服务端要求上报属性 | PUT 属性数据 | +| `installedBase` | 有历史安装记录 | 可查询 | + +--- + +## 3. 分配 Distribution Set + +``` +POST /rest/v1/distributionsets/{distributionSetId}/assignedTargets +``` + +> ⚠️ 这是 Management API,用 `-u admin:admin` 认证。 + +```bash +curl -u admin:admin -X POST \ + -H "Content-Type: application/json" \ + "http://192.168.142.128:9090/rest/v1/distributionsets/1/assignedTargets" \ + -d '[{"id": "test-curl-001"}]' +``` + +**可选参数(完整请求体)**: + +```json +{ + "id": "test-curl-001", + "type": "forced", + "forcetime": 1682408575278, + "weight": 1000, + "maintenanceWindow": { + "schedule": "00:00-04:00", + "duration": "02:00:00", + "timezone": "+08:00" + }, + "confirmationRequired": false +} +``` + +**响应**:`200 OK`,返回 `MgmtTargetAssignmentResponseBody`。 + +分配成功后,设备下次轮询就会拿到 `deploymentBase` 链接。 + +--- + +## 4. 拉取部署详情 + +``` +GET /{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId} +``` + +```bash +curl -H "Authorization: GatewayToken my-gateway-key" \ + -H "Accept: application/json" \ + "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/deploymentBase/15" +``` + +**响应**: + +```json +{ + "id": "15", + "deployment": { + "download": "forced", + "update": "forced", + "chunks": [ + { + "part": "os", + "version": "V1.0.0.0", + "name": "r3576-OS-android", + "artifacts": [ + { + "filename": "update.zip", + "hashes": { + "sha1": "223377ea691bd7fc47865cccfd802771ff1b3fca", + "md5": "28a17f5fb87d637756c0cc5d555d69b7", + "sha256": "438b8f9e2af3a3334dc72272d167e0deda8c6e65bb1585202151b3cb866bb530" + }, + "size": 195411, + "_links": { + "download-http": { + "href": "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/softwaremodules/1/artifacts/update.zip" + }, + "md5sum-http": { + "href": "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/softwaremodules/1/artifacts/update.zip.MD5SUM" + } + } + } + ] + } + ] + } +} +``` + +### 字段说明 + +| 字段 | 说明 | +|------|------| +| `id` | action ID,后续反馈操作的关键参数 | +| `deployment.download` | `forced`=强制下载 / `attempt`=尝���下载 / `skip`=跳过 | +| `deployment.update` | `forced`=强制安装 / `attempt`=尝试安装 / `skip`=跳过 | +| `chunks[].part` | 软件模块类型(os / app / …) | +| `chunks[].version` | 软件版本号 | +| `chunks[].artifacts[].filename` | 要下载的文件名 | +| `chunks[].artifacts[].hashes` | SHA1 / MD5 / SHA256 校验值 | +| `chunks[].artifacts[].size` | 文件大小(bytes) | +| `_links.download-http.href` | **下载地址** | + +### download / update handlingType 组合 + +| download | update | 含义 | +|----------|--------|------| +| `forced` | `forced` | 立即下载并安装 | +| `attempt` | `attempt` | 设备自行决定是否下载/安装 | +| `forced` | `skip` | 只下载不安装(download only) | +| `skip` | `skip` | maintenance window 未到,等待下次轮询 | + +--- + +## 5. 上报设备属性 + +``` +PUT /{tenant}/controller/v1/{controllerId}/configData +``` + +> ⚠️ 必须先轮询一次让 target 自动注册,再上报属性,否则 404。 + +```bash +curl -H "Authorization: GatewayToken my-gateway-key" \ + -H "Content-Type: application/json" -X PUT \ + "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/configData" \ + -d '{ + "mode": "merge", + "data": { + "osType": "linux", + "platform": "x86_64", + "kernel": "6.17.0-35-generic", + "hostname": "test-curl-001", + "hwRevision": "VMware Virtual Platform", + "manufacturer": "VMware, Inc.", + "swVersion": "V1.0.0.0" + } + }' +``` + +### mode 说明 + +| mode | 行为 | +|------|------| +| `merge` | 合并:只更新传入的 key,保留已有属性(推荐) | +| `replace` | 替换:完全覆盖已有属性 | +| `remove` | 删除:移除传入的 key | + +**响应**:`200 OK`(空 body) + +### 查询设备属性 + +```bash +curl -u admin:admin \ + "http://192.168.142.128:9090/rest/v1/targets/test-curl-001/attributes" +``` + +--- + +## 6. 下载 artifact + +``` +GET /{tenant}/controller/v1/{controllerId}/softwaremodules/{softwareModuleId}/artifacts/{fileName} +``` + +### 6.1 完整下载 + +```bash +curl -H "Authorization: GatewayToken my-gateway-key" \ + -o /tmp/test-update.zip \ + "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/softwaremodules/1/artifacts/update.zip" +``` + +**响应**:`200 OK`,二进制流。 + +### 6.2 断点续传(Range 请求) + +```bash +# 续传:从 byte 1048576 开始 +curl -H "Authorization: GatewayToken my-gateway-key" \ + -H "Range: bytes=1048576-" \ + -o /tmp/test-update.zip.part --append \ + "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/softwaremodules/1/artifacts/update.zip" +``` + +**响应**:`206 Partial Content`,`Content-Range: bytes 1048576-195410/195411`。 + +### 6.3 条件请求(If-Match) + +```bash +curl -H "Authorization: GatewayToken my-gateway-key" \ + -H "If-Match: 223377ea691bd7fc47865cccfd802771ff1b3fca" \ + -o /tmp/test-update.zip \ + "http://..." +# SHA1 不匹配 → 412 Precondition Failed +``` + +### 6.4 下载 MD5SUM + +```bash +curl -H "Authorization: GatewayToken my-gateway-key" \ + "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/softwaremodules/1/artifacts/update.zip.MD5SUM" +``` + +**响应**:`28a17f5fb87d637756c0cc5d555d69b7 update.zip` + +### 6.5 从 HAL 链接提取下载地址 + +artifact 的 `_links` 中有两个下载链接,优先级: + +``` +download → HTTPS(如果有,首选) +download-http → HTTP(回退方案) +md5sum-http → MD5 校验文件(HTTP) +``` + +--- + +## 7. SHA256 文件校验 + +### 7.1 计算本地文件 SHA256 + +```bash +sha256sum /tmp/test-update.zip +``` + +### 7.2 对比部署详情中的哈希值 + +```bash +# 部署详情中的 SHA256: +# "sha256": "438b8f9e2af3a3334dc72272d167e0deda8c6e65bb1585202151b3cb866bb530" + +echo "438b8f9e2af3a3334dc72272d167e0deda8c6e65bb1585202151b3cb866bb530 /tmp/test-update.zip" | sha256sum -c +# → /tmp/test-update.zip: OK +``` + +--- + +## 8. 上报执行状态 + +``` +POST /{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}/feedback +``` + +### 8.1 状态上报 JSON 格式 + +```json +{ + "timestamp": 1750000000000, + "status": { + "execution": "download", + "result": { + "finished": "none", + "progress": {"cnt": 2, "of": 5} + }, + "code": 200, + "details": ["Starting download"] + } +} +``` + +### 8.2 四步标准流程 + +```bash +# 定义变量,方便重复使用 +D="http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/deploymentBase/15/feedback" +A="Authorization: GatewayToken my-gateway-key" +J="Content-Type: application/json" + +# ① download — 开始下载 +curl -H "$A" -H "$J" \ + -d '{"status":{"execution":"download","result":{"finished":"none"},"details":["Starting download"]}}' \ + "$D" + +# ② downloaded — 下载完成 +curl -H "$A" -H "$J" \ + -d '{"status":{"execution":"downloaded","result":{"finished":"none"},"details":["Downloaded update.zip"]}}' \ + "$D" + +# ③ proceeding — 安装中 +curl -H "$A" -H "$J" \ + -d '{"status":{"execution":"proceeding","result":{"finished":"none"},"details":["Installing update"]}}' \ + "$D" + +# ④ closed — 最终结果 +# 成功: +curl -H "$A" -H "$J" \ + -d '{"status":{"execution":"closed","result":{"finished":"success"},"details":["Installation complete"],"code":200}}' \ + "$D" + +# 失败: +curl -H "$A" -H "$J" \ + -d '{"status":{"execution":"closed","result":{"finished":"failure"},"details":["Installation failed"],"code":500}}' \ + "$D" +``` + +### 8.3 execution 枚举值 + +| execution | Hawkbit Status | 说明 | +|-----------|---------------|------| +| `download` | `DOWNLOAD` | 开始下载 | +| `downloaded` | `DOWNLOADED` | 下载完成 | +| `proceeding` | `RUNNING` | 安装进行中 | +| `closed` | `FINISHED` / `ERROR` | 最终状态(配合 result.finished) | +| `canceled` | `CANCELED` | 已取消 | +| `rejected` | `WARNING` | 已拒绝 | +| `scheduled` | `RUNNING` | 已计划 | +| `resumed` | `RUNNING` | 恢复执行 | + +### 8.4 result.finished 枚举值 + +| finished | 含义 | +|----------|------| +| `none` | 尚未完成(中间状态) | +| `success` | 执行成功 | +| `failure` | 执行失败 | + +### 8.5 带进度的上报 + +```bash +# 进度: 第2个/总共5个 artifact +curl -H "$A" -H "$J" \ + -d '{"status":{"execution":"proceeding","result":{"finished":"none","progress":{"cnt":2,"of":5}},"details":["Downloading artifact 2/5"]}}' \ + "$D" +``` + +### 8.6 取消反馈(cancelAction feedback) + +```bash +curl -H "$A" -H "$J" \ + -d '{"status":{"execution":"canceled","result":{"finished":"none"},"details":["Cancel acknowledged"]}}' \ + "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/cancelAction/15/feedback" +``` + +### 8.7 确认反馈(confirmationBase feedback) + +```bash +curl -H "$A" -H "$J" \ + -d '{"confirmation":"confirmed","code":200,"details":["Auto-confirmed"]}' \ + "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/confirmationBase/15/feedback" +``` + +### 8.8 常见错误 + +| HTTP 状态码 | 原因 | 解决 | +|------------|------|------| +| `401` | 认证未通过 | 检查 Authorization header 格式 | +| `404` | URL 路径错误或 target 不存在 | 确保 `/{tenant}/controller/v1/...` | +| `405` | feedback URL 带了 `?c=xxx` 参数 | POST 前剥离查询参数 | +| `410 Gone` | action 已不再活跃 | 已经 closed 或 canceled | +| `400 Body Not Readable` | JSON 格式错误 | `"final"` 应为 `"finished"`,`"progress":{}` 应为 `null` 或省略 | + +--- + +## 9. 取消任务 + +### 9.1 服务端发起取消(Management API) + +```bash +curl -u admin:admin -X DELETE \ + "http://192.168.142.128:9090/rest/v1/targets/test-curl-001/actions/15" +``` + +### 9.2 设备端处理取消 + +```bash +# ① 轮询拿到 cancelAction +curl -H "Authorization: GatewayToken my-gateway-key" \ + "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001" +# → _links.cancelAction.href + +# ② 确认取消 +curl -H "Authorization: GatewayToken my-gateway-key" \ + -H "Content-Type: application/json" \ + -d '{"status":{"execution":"canceled","result":{"finished":"none"},"details":["Acknowledged"]}}' \ + "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/cancelAction/15/feedback" +``` + +--- + +## 10. 确认任务流程 + +### 10.1 开启确认流程(服务端) + +```bash +curl -u admin:admin -X PUT \ + -H "Content-Type: application/json" \ + "http://192.168.142.128:9090/rest/v1/system/configs/user.confirmation.flow.enabled" \ + -d '{"value": true}' +``` + +### 10.2 分配时要求确认 + +```bash +curl -u admin:admin -X POST \ + -H "Content-Type: application/json" \ + "http://192.168.142.128:9090/rest/v1/distributionsets/1/assignedTargets" \ + -d '[{"id":"test-curl-001","confirmationRequired":true}]' +``` + +### 10.3 设备端自动确认 + +```bash +curl -H "Authorization: GatewayToken my-gateway-key" \ + -H "Content-Type: application/json" \ + -d '{"confirmation":"confirmed","code":200,"details":["Auto-confirmed"]}' \ + "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/confirmationBase/15/feedback" +``` + +--- + +## 11. 查询验证 + +### 11.1 查询 target 基本信息 + +```bash +curl -u admin:admin \ + "http://192.168.142.128:9090/rest/v1/targets/test-curl-001" +``` + +### 11.2 查询 target 所有 action + +```bash +curl -u admin:admin \ + "http://192.168.142.128:9090/rest/v1/targets/test-curl-001/actions" +``` + +### 11.3 查询指定 action 详情 + +```bash +curl -u admin:admin \ + "http://192.168.142.128:9090/rest/v1/targets/test-curl-001/actions/15" +``` + +### 11.4 查询 action 状态历史 + +```bash +curl -u admin:admin \ + "http://192.168.142.128:9090/rest/v1/targets/test-curl-001/actions/15/status" +``` + +**响应示例(完成全流程后)**: + +```json +{ + "content": [ + {"id":58, "type": "finished", "messages": ["Installation complete"], ...}, + {"id":57, "type": "running", "messages": ["Installing update"], ...}, + {"id":56, "type": "downloaded","messages": ["Downloaded update.zip"], ...}, + {"id":55, "type": "download", "messages": ["Starting download"], ...}, + {"id":54, "type": "download", "messages": ["Target downloads ..."], ...}, + {"id":53, "type": "retrieved", "messages": ["Target retrieved ..."], ...}, + {"id":52, "type": "running", "messages": ["Assignment initiated ..."]} + ], + "total": 7, "size": 7 +} +``` + +### 11.5 查询 target 属性 + +```bash +curl -u admin:admin \ + "http://192.168.142.128:9090/rest/v1/targets/test-curl-001/attributes" +``` + +### 11.6 查询所有 target + +```bash +curl -u admin:admin \ + "http://192.168.142.128:9090/rest/v1/targets" +``` + +### 11.7 查询所有 Distribution Set + +```bash +curl -u admin:admin \ + "http://192.168.142.128:9090/rest/v1/distributionsets" +``` + +--- + +## 12. 完整流程速查 + +### 12.1 一次完整的 OTA 生命周期(复制粘贴即用) + +```bash +# ═══ 变量设置 ═══ +HOST="http://192.168.142.128:9090" +TENANT="DEFAULT" +CID="test-curl-001" +AUTH="Authorization: GatewayToken my-gateway-key" +ADMIN="admin:admin" + +# ═══ Step 1: 轮询(自动注册)═══════════════════════════════════════ +curl -s -H "$AUTH" -H "Accept: application/json" \ + "$HOST/$TENANT/controller/v1/$CID" | python3 -m json.tool + +# ═══ Step 2: 分配 Distribution Set(Management API)═════════════════ +curl -u $ADMIN -X POST -H "Content-Type: application/json" \ + "$HOST/rest/v1/distributionsets/1/assignedTargets" \ + -d '[{"id": "'$CID'"}]' + +# ═══ Step 3: 轮询(拿到 deploymentBase)═════════════════════════════ +RESP=$(curl -s -H "$AUTH" -H "Accept: application/json" \ + "$HOST/$TENANT/controller/v1/$CID") +echo "$RESP" | python3 -m json.tool + +# 提取 actionId(假设第一次分配,取 deploymentBase URL 中的数字) +DEPLOY_URL=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['_links']['deploymentBase']['href'])") +AID=$(echo "$DEPLOY_URL" | grep -oP 'deploymentBase/\K\d+') +echo "Action ID: $AID" + +# ═══ Step 4: 拉取部署详情 ══════════════════════════════════════════ +DEPLOY=$(curl -s -H "$AUTH" -H "Accept: application/json" \ + "$HOST/$TENANT/controller/v1/$CID/deploymentBase/$AID") +echo "$DEPLOY" | python3 -m json.tool + +# 提取下载地址和 SHA256 +DL_URL=$(echo "$DEPLOY" | python3 -c " +import sys,json +art = json.load(sys.stdin)['deployment']['chunks'][0]['artifacts'][0] +print(art['_links']['download-http']['href']) +") +SHA256=$(echo "$DEPLOY" | python3 -c " +import sys,json +print(json.load(sys.stdin)['deployment']['chunks'][0]['artifacts'][0]['hashes']['sha256']) +") +echo "Download: $DL_URL" +echo "SHA256: $SHA256" + +# ═══ Step 5: 上报属性 ══════════════════════════════════════════════ +curl -s -H "$AUTH" -H "Content-Type: application/json" -X PUT \ + "$HOST/$TENANT/controller/v1/$CID/configData" \ + -d '{"mode":"merge","data":{"osType":"linux","hostname":"'$CID'","swVersion":"V1.0.0.0"}}' + +# ═══ Step 6: 下载 ══════════════════════════════════════════════════ +curl -H "$AUTH" -o /tmp/update.zip "$DL_URL" + +# ═══ Step 7: 校验 ══════════════════════════════════════════════════ +echo "$SHA256 /tmp/update.zip" | sha256sum -c + +# ═══ Step 8: 上报四步状态 ══════════════════════════════════════════ +FB="$HOST/$TENANT/controller/v1/$CID/deploymentBase/$AID/feedback" + +curl -s -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"status":{"execution":"download","result":{"finished":"none"},"details":["Starting"]}}' "$FB" + +curl -s -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"status":{"execution":"downloaded","result":{"finished":"none"},"details":["Downloaded"]}}' "$FB" + +curl -s -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"status":{"execution":"proceeding","result":{"finished":"none"},"details":["Installing"]}}' "$FB" + +curl -s -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"status":{"execution":"closed","result":{"finished":"success"},"details":["Installation complete"],"code":200}}' "$FB" + +# ═══ Step 9: 验证 ══════════════════════════════════════════════════ +curl -s -u $ADMIN "$HOST/rest/v1/targets/$CID/actions/$AID/status" | python3 -m json.tool + +echo "Done! Action $AID is now finished." +``` + +### 12.2 Hawkbit 状态流转图 + +``` + 管理员分配 DS + │ + ▼ + RUNNING WAITING_FOR_CONFIRMATION + │ │ + ▼ │ confirmed + RETRIEVED ◄─── deploymentBase ▼ + │ (轮询返回) RUNNING + ▼ │ + DOWNLOAD ... + │ + ▼ + DOWNLOADED + │ + ▼ + PROCEEDING ──→ status: RUNNING + │ + ┌───┴───┐ + ▼ ▼ +FINISHED ERROR ◄─── closed + success/failure + (成功) (失败) + + 任意阶段 ──cancel──→ CANCELING ──→ CANCELED +``` + +### 12.3 API 端点速查表 + +| 操作 | 方法 | 路径 | 认证 | +|------|------|------|------| +| 轮询 | GET | `/{tenant}/controller/v1/{controllerId}` | DDI | +| 部署详情 | GET | `/{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}` | DDI | +| 上报反馈 | POST | `.../deploymentBase/{actionId}/feedback` | DDI | +| 取消反馈 | POST | `.../cancelAction/{actionId}/feedback` | DDI | +| 确认反馈 | POST | `.../confirmationBase/{actionId}/feedback` | DDI | +| 上报属性 | PUT | `/{tenant}/controller/v1/{controllerId}/configData` | DDI | +| 下载文件 | GET | `/{tenant}/controller/v1/{controllerId}/softwaremodules/{id}/artifacts/{file}` | DDI | +| 下载MD5 | GET | `.../softwaremodules/{id}/artifacts/{file}.MD5SUM` | DDI | +| 分配 DS | POST | `/rest/v1/distributionsets/{id}/assignedTargets` | Basic | +| 查询 target | GET | `/rest/v1/targets/{controllerId}` | Basic | +| 查询 action | GET | `/rest/v1/targets/{controllerId}/actions/{id}` | Basic | +| 查询状态 | GET | `/rest/v1/targets/{controllerId}/actions/{id}/status` | Basic | +| 查询属性 | GET | `/rest/v1/targets/{controllerId}/attributes` | Basic | +| 取消 action | DELETE | `/rest/v1/targets/{controllerId}/actions/{id}` | Basic | +| 配置管理 | GET/PUT/DELETE | `/rest/v1/system/configs/{key}` | Basic | diff --git a/hawkbit-compose/README.md b/hawkbit-compose/README.md new file mode 100644 index 000000000..404592d0a --- /dev/null +++ b/hawkbit-compose/README.md @@ -0,0 +1,214 @@ +# Hawkbit DDI Client + +轻量级纯 Python OTA 更新代理,零依赖(仅 Python 3 标准库),可在 **Android adb shell** 和 **Linux 主机**上运行。 + +## 功能 + +| 功能 | 说明 | +|------|------| +| **轮询** | `GET /{tenant}/controller/v1/{controllerId}` 查询部署任务,自动适配服务端建议的轮询间隔 | +| **断点下载** | HTTP `Range` 请求实现断点续传,临时 `.tmp` 文件保留至下载完成 | +| **SHA256 校验** | 下载完成后自动与部署元数据中的哈希值比对 | +| **状态上报** | 完整状态链:`download` → `downloaded` → `proceeding` → `closed(success/failure)` | +| **设备 SN 检测** | 自动检测序列号:Android `ro.serialno` → DMI `product_serial` → `/etc/machine-id` → hostname | +| **设备属性上报** | 首次连接自动上报设备属性(osType, platform, hwRevision, kernel…),供 Hawkbit target filter 匹配 | +| **认证** | 支持 `GatewayToken` 和 `TargetToken`,也支持从文件读取:`--gateway-token @/etc/hawkbit/gateway.key` | +| **取消/确认** | 处理 `cancelAction`(取消任务)和 `confirmationBase`(headless 自动确认) | + +### 设备属性上报 + +脚本在**首次轮询**时自动调用 `PUT …/configData` 上报以下属性: + +| 属性 | 来源 | 示例 | +|------|------|------| +| `osType` | `/system/build.prop` 或 uname | `android` / `linux` | +| `platform` | `platform.machine()` | `aarch64` / `x86_64` | +| `kernel` | `os.uname().release` | `6.17.0-35-generic` | +| `hwRevision` | DMI product_name + product_version | `VMware Virtual Platform` | +| `manufacturer` | DMI sys_vendor 或 Android ro.product.manufacturer | `VMware, Inc.` | +| `hostname` | `socket.gethostname()` | `device-001` | +| `swVersion` | `/etc/hawkbit/current_version`(可选) | `V1.0.0.0` | + +**扩展属性**:创建 `/etc/hawkbit/device_attrs.json` 添加自定义属性: + +```json +{"dept": "production", "region": "east", "owner": "team-a"} +``` + +这些属性在 Hawkbit 里就是 target 的 attributes,管理员创建 target filter 时可以直接用: + +``` +属性 osType=android AND hwRevision=VMware → 自动分配 V2.0 更新 + +## 快速开始 + +```bash +# 网关令牌,每 30 秒轮询 +python3 ddi-client.py -u https://hawkbit.example.com -t DEFAULT --gateway-token s3cret -i 30 + +# 网关令牌从文件读取(生产推荐) +echo "s3cret" > /etc/hawkbit/gateway.key +chmod 600 /etc/hawkbit/gateway.key +python3 ddi-client.py -u https://hawkbit.example.com -t DEFAULT \ + --gateway-token @/etc/hawkbit/gateway.key -i 30 + +# 目标令牌,单次轮询 +python3 ddi-client.py -u http://10.0.0.1:8080 -t DEFAULT --target-token tok --once + +# 自定义设备 ID + 自定义属性文件,调试模式 +python3 ddi-client.py -u https://hawkbit:8443 -t prod \ + --gateway-token @/etc/hawkbit/gateway.key \ + --controller-id edge-042 -d /data/ota -v +``` + +## 命令行参数 + +``` +-u, --base-url Hawkbit 服务端地址(必填) +-t, --tenant 租户名称,如 DEFAULT(必填) +--gateway-token Gateway 安全令牌 +--target-token Target 安全令牌 +--controller-id 控制器 ID,默认自动检测设备序列号 +-d, --download-dir 下载目录,默认 /tmp/hawkbit +-i, --polling-interval 轮询间隔(秒),默认 60,可被服务端覆盖 +--no-verify 跳过 SHA256 校验 +--no-resume 禁用断点续传 +--no-ssl-verify 禁用 TLS 证书验证(不安全) +--once 仅轮询一次后退出 +-v, --verbose 调试级日志 +-h, --help 帮助信息 +``` + +## DDI API 流程 + +``` + ┌─────────┐ ① GET /{tenant}/controller/v1/{controllerId} ┌──────────┐ + │ │ ────────────────────────────────────────────────────→ │ │ + │ 设备 │ ←──── HAL links (deploymentBase / cancel / …) │ Hawkbit │ + │ │ │ Server │ + │ │ ② GET …/deploymentBase/{actionId} │ │ + │ │ ────────────────────────────────────────────────────→ │ │ + │ │ ←──── chunks[].artifacts[] (filename, SHA256, │ │ + │ │ download URL) │ │ + │ │ │ │ + │ │ ③ GET …/softwaremodules/{id}/artifacts/{file} │ │ + │ │ ────────────────────────────────────────────────────→ │ │ + │ │ ←──── 二进制流(支持 Range 断点续传) │ │ + │ │ │ │ + │ │ ④ POST …/deploymentBase/{actionId}/feedback │ │ + │ │ ───── {execution, result, details} ────────────────→ │ │ + │ │ ←──── 200 OK │ │ + └─────────┘ └──────────┘ +``` + +### 状态上报 JSON 格式 + +```json +{ + "timestamp": 1750000000000, + "status": { + "execution": "download", + "result": { + "finished": "none" + }, + "details": ["Starting download"] + } +} +``` + +| execution 值 | 含义 | result.finished | +|-------------|------|-----------------| +| `download` | 开始下载 | `none` | +| `downloaded` | 下载完成 | `none` | +| `proceeding` | 安装中 | `none` | +| `closed` | 最终状态 | `success` / `failure` | +| `canceled` | 已取消 | `none` | +| `rejected` | 已拒绝 | `none` | + +## 认证配置 + +### Target Token(推荐,每设备独立令牌) + +在 Hawkbit Management API 中为目标设置 security token: + +```bash +curl -u admin:admin -X PUT \ + -H "Content-Type: application/json" \ + "http://:9090/rest/v1/targets/" \ + -d '{"securityToken": "my-device-token"}' +``` + +启用租户级 TargetToken 认证: + +```bash +curl -u admin:admin -X PUT \ + -H "Content-Type: application/json" \ + "http://:9090/rest/v1/system/configs/authentication.targettoken.enabled" \ + -d '{"value": true}' +``` + +### Gateway Token(所有设备共享同一令牌) + +```bash +# 设置 token key +curl -u admin:admin -X PUT \ + -H "Content-Type: application/json" \ + "http://:9090/rest/v1/system/configs/authentication.gatewaytoken.key" \ + -d '{"value": "my-gateway-key"}' + +# 启用 +curl -u admin:admin -X PUT \ + -H "Content-Type: application/json" \ + "http://:9090/rest/v1/system/configs/authentication.gatewaytoken.enabled" \ + -d '{"value": true}' +``` + +## 设备 SN 检测优先级 + +| 优先级 | 来源 | 适用平台 | +|--------|------|---------| +| 1 | `getprop ro.serialno` | Android | +| 2 | `/sys/class/dmi/id/product_serial` | Linux (x86) | +| 3 | `/sys/devices/virtual/dmi/id/product_serial` | Linux (ARM / 嵌入式) | +| 4 | `/etc/machine-id` | systemd Linux | +| 5 | `socket.gethostname()` | 通用回退 | + +## 安装钩子 + +下载完成后,脚本调用 `_install(files, handling)` 方法执行实际安装。**默认实现仅做文件存在性检查,生产环境必须覆盖此方法**。 + +### 方式一:子类继承 + +```python +from ddi_client import HawkbitDDIClient + +class MyClient(HawkbitDDIClient): + def _install(self, files, handling): + import subprocess + for f in files: + subprocess.run(["unzip", "-o", f, "-d", "/data/ota"], check=True) + return True +``` + +### 方式二:Monkey-Patch + +```python +client = HawkbitDDIClient(...) +client._install = lambda files, h: do_my_install(files) +``` + +## 兼容性 + +- Python ≥ 3.7 +- Android (adb shell, 需安装 Python) +- Linux (x86_64 / aarch64 / armv7l) +- macOS +- Hawkbit DDI API v1 + +## 文件结构 + +``` +ddi-client.py # 脚本本体(单文件,可直接部署) +``` + +无其他依赖文件。 diff --git a/hawkbit-compose/RUNBOOK.md b/hawkbit-compose/RUNBOOK.md new file mode 100644 index 000000000..afc11c5bc --- /dev/null +++ b/hawkbit-compose/RUNBOOK.md @@ -0,0 +1,902 @@ +# Hawkbit DDI Client — 运行流程文档 + +## 目录 + +1. [整体架构](#1-整体架构) +2. [首次注册流程](#2-首次注册流程) +3. [守护进程轮询流程](#3-守护进程轮询流程) +4. [单次轮询流程](#4-单次轮询流程) +5. [OTA 部署流程](#5-ota-部署流程) +6. [认证流程](#6-认证流程) +7. [取消任务流程](#7-取消任务流程) +8. [确认任务流程](#8-确认任务流程) +9. [断点续传流程](#9-断点续传流程) +10. [错误处理流程](#10-错误处理流程) +11. [安装钩子定制](#11-安装钩子定制) +12. [部署与运维](#12-部署与运维) + +--- + +## 1. 整体架构 + +``` +┌──────────────────────────────────────────────────────────┐ +│ ddi-client.py │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ 属性检测 │ │ 认证模块 │ │ 轮询模块 │ │ 安装钩子 │ │ +│ │ (SN/OS/ │ │ Gateway/ │ │ Poll/ │ │ _install()│ │ +│ │ HW/Kernel│ │ Target │ │ Deploy/ │ │ 可覆盖 │ │ +│ │ ...) │ │ Token │ │ Download │ │ │ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ +│ │ │ │ │ │ +│ ▼ ▼ ▼ ▼ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ HTTP 层 │ │ +│ │ (urllib + SSL context + Range 支持) │ │ +│ └──────────────────────┬───────────────────────────┘ │ +└─────────────────────────┼────────────────────────────────┘ + │ + ▼ + ┌──────────────────────┐ + │ Hawkbit Server │ + │ DDI API v1 (9090) │ + └──────────────────────┘ +``` + +### 数据流 + +``` +属性文件 ──┐ + ├──→ 属性检测 ──→ PUT /configData ──→ Hawkbit 记录 target attributes +SN 检测 ──┘ + +轮询 ──→ GET /controller/v1/{SN} + ←── deploymentBase / cancelAction / confirmationBase / configData / 无 + +部署 ──→ GET /deploymentBase/{id} ←── chunks[].artifacts[] +下载 ──→ GET /softwaremodules/{id}/... ←── 二进制流 (Range 支持) +反馈 ──→ POST /feedback ←── 200 OK +``` + +--- + +## 2. 首次注册流程 + +**触发条件**:设备 SN 在 Hawkbit 中不存在(或脚本首次连接后重启) + +``` +启动参数: + ddi-client.py -u http://192.168.142.128:9090 -t DEFAULT \ + --gateway-token @/etc/hawkbit/gateway.key + +═══════════════════════════════════════════════════════════════ + Step 1: 读取 Gateway Token +═══════════════════════════════════════════════════════════════ + 输入: @/etc/hawkbit/gateway.key + 动作: open("/etc/hawkbit/gateway.key") → read() → strip() + 输出: Authorization: GatewayToken <文件内容> + + 备选: --gateway-token <明文key> 直接传入 + --target-token <明文token> 直接传入 + --target-token @/etc/hawkbit/target.token 文件读取 + +═══════════════════════════════════════════════════════════════ + Step 2: 检测设备 SN +═══════════════════════════════════════════════════════════════ + 优先级: + ① getprop ro.serialno → Android 序列号 + ② /sys/class/dmi/id/product_serial → Linux DMI (x86) + ③ /sys/devices/virtual/dmi/id/... → Linux DMI (ARM/嵌入式) + ④ /etc/machine-id → systemd 机器 ID + ⑤ socket.gethostname() → 兜底 + + 跳过条件: --controller-id 显式指定 + 输出: controllerId = "89aa36c5b6744dc2910479f7ecd6d2c3" + +═══════════════════════════════════════════════════════════════ + Step 3: 检测设备属性 +═══════════════════════════════════════════════════════════════ + 自动检测: + osType → /system/build.prop 存在 ? "android" : "linux" + platform → platform.machine() # x86_64 / aarch64 + kernel → os.uname().release # 6.17.0-35-generic + hostname → socket.gethostname() + hwRevision → DMI product_name + product_version + manufacturer→ DMI sys_vendor / Android ro.product.manufacturer + + Android 额外属性: + androidVersion → ro.build.version.release + productModel → ro.product.model + buildNumber → ro.build.version.incremental + + 用户属性文件 (/etc/hawkbit/device_attrs.json): + {"dept": "production", "region": "east", "owner": "team-a"} + + 版本文件 (/etc/hawkbit/current_version) : + V1.0.0.0 → swVersion + +═══════════════════════════════════════════════════════════════ + Step 4: 上报属性 +═══════════════════════════════════════════════════════════════ + PUT http://host/DEFAULT/controller/v1/{SN}/configData + Body: + { + "mode": "merge", + "data": { + "osType": "linux", + "platform": "x86_64", + "kernel": "6.17.0-35-generic", + "hostname": "device-001", + "hwRevision": "VMware Virtual Platform", + "manufacturer": "VMware, Inc.", + "swVersion": "V1.0.0.0", + "dept": "production", + "region": "east" + } + } + + mode 说明: + "merge" → 只更新传入的 key,保留已有属性 (推荐) + "replace" → 完全替换 + "remove" → 删除传入的 key + +═══════════════════════════════════════════════════════════════ + Step 5: 首次轮询(自动注册) +═══════════════════════════════════════════════════════════════ + GET http://host/DEFAULT/controller/v1/{SN} + Hawkbit 行为: + findOrRegisterTargetIfItDoesNotExist(SN) + → 不存在 → INSERT INTO target (controller_id, ip_address, ...) + → 存在 → UPDATE last_target_query + + 响应: + 无 action: + {"config": {"polling": {"sleep": "00:05:00"}}, "_links": {}} + → sleep 5分钟 + + 有 deployment: + "_links": { + "deploymentBase": {"href": "http://.../deploymentBase/1?c=..."}, + "configData": {"href": "http://.../configData"} + } + → 进入 OTA 部署流程 + + 有 cancel: + "_links": {"cancelAction": {"href": "http://.../cancelAction/..."}} + → 进入取消流程 + + 有 confirmation: + "_links": {"confirmationBase": {"href": "http://.../confirmationBase"}} + → 进入确认流程 +``` + +--- + +## 3. 守护进程轮询流程 + +``` +启动命令: + ddi-client.py -u http://192.168.142.128:9090 -t DEFAULT \ + --gateway-token my-key -i 60 + +┌─────────────────────────────────────────────────┐ +│ 守护模式 (daemon=True) │ +├─────────────────────────────────────────────────┤ +│ │ +│ ┌──────────┐ │ +│ │ 启动/打印 │ ╔═══════════════════╗ │ +│ │ banner │ ║ Server: host ║ │ +│ └────┬─────┘ ║ Tenant: DEFAULT ║ │ +│ │ ║ Device: ║ │ +│ ▼ ║ Auth: Gateway ║ │ +│ ┌──────────┐ ╚═══════════════════╝ │ +│ │ 上报属性 │ PUT /configData │ +│ │(首次) │ (仅一次, 成功即跳过) │ +│ └────┬─────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────┐ │ +│ │ 轮询 │ GET /controller/v1/{SN} │ +│ │ Poll │ │ +│ └────┬─────┘ │ +│ │ │ +│ ┌───┴───┬───────────┬───────────┐ │ +│ ▼ ▼ ▼ ▼ │ +│ deploy cancel confirm 无任务 │ +│ │ │ │ │ │ +│ ▼ ▼ ▼ │ │ +│ OTA流程 取消流程 确认流程 │ │ +│ │ │ │ │ │ +│ └───┴───┴─────────┴───────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────┐ │ +│ │ sleep(N 秒) │ N = max(server建议, │ +│ │ │ 默认60s) │ +│ └──────┬───────┘ │ +│ │ │ +│ └──────→ 回到轮询 ◄── 循环 │ +│ │ +│ 退出: Ctrl+C (KeyboardInterrupt) │ +│ max_cycles 达到上限 │ +└─────────────────────────────────────────────────┘ + +轮询间隔策略: + - 默认: --polling-interval 60 (或 -i 指定) + - 服务端可覆盖: response.config.polling.sleep → "00:05:00" → 5分钟 + - 有活跃 action 时: 服务端可能缩短间隔 (getPollingTimeForAction) + - 轮询失败时: 退避到 min(polling_interval, 30s) +``` + +--- + +## 4. 单次轮询流程 + +``` +启动命令: + ddi-client.py -u http://host:9090 -t DEFAULT \ + --gateway-token my-key --once + +┌───────────────────────────────────────────────────────────┐ +│ 单次模式 (daemon=False, --once) │ +├───────────────────────────────────────────────────────────┤ +│ │ +│ ① 上报属性 (首次) │ +│ │ │ +│ ▼ │ +│ ② 轮询 GET /controller/v1/{SN} │ +│ │ │ +│ ┌───┴───┬───────────┬───────────┐ │ +│ ▼ ▼ ▼ ▼ │ +│ deploy cancel confirm 无任务 │ +│ │ │ │ │ │ +│ ▼ ▼ ▼ │ │ +│ OTA流程 取消流程 确认流程 │ │ +│ │ │ │ │ │ +│ └───┴───┴─────────┴───────────┘ │ +│ │ │ +│ ▼ │ +│ ③ sys.exit(0) │ +│ │ +│ 用途: │ +│ - systemd timer / cron 定时触发 │ +│ - 测试/调试 │ +│ - 脚本化: && 链式调用后续操作 │ +└───────────────────────────────────────────────────────────┘ + +cron 示例: + */5 * * * * /usr/bin/python3 /opt/hawkbit/ddi-client.py \ + -u https://hawkbit:9090 -t DEFAULT \ + --gateway-token @/etc/hawkbit/gateway.key --once + +systemd timer 示例: + [Unit] + Description=Hawkbit DDI poll + [Service] + ExecStart=/usr/bin/python3 /opt/hawkbit/ddi-client.py \ + -u https://hawkbit:9090 -t DEFAULT \ + --gateway-token @/etc/hawkbit/gateway.key --once + [Timer] + OnBootSec=30s + OnUnitActiveSec=300s +``` + +--- + +## 5. OTA 部署流程 + +``` +前置条件: 轮询返回 _links.deploymentBase + +┌───────────────────────────────────────────────────────────────┐ +│ OTA 部署完整生命周期 │ +├───────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Step A: 拉取部署详情 │ │ +│ │ │ │ +│ │ GET /deploymentBase/{actionId}?c=... │ │ +│ │ ← { │ │ +│ │ "id": "2", │ │ +│ │ "deployment": { │ │ +│ │ "download": "forced", │ │ +│ │ "update": "forced", │ │ +│ │ "chunks": [{ │ │ +│ │ "part": "os", │ │ +│ │ "version": "V1.0.0.0", │ │ +│ │ "name": "r3576-OS-android", │ │ +│ │ "artifacts": [{ │ │ +│ │ "filename": "update.zip", │ │ +│ │ "size": 195411, │ │ +│ │ "hashes": { │ │ +│ │ "sha256": "438b8f9e...", │ │ +│ │ "md5": "28a17f5f...", │ │ +│ │ "sha1": "223377ea..." │ │ +│ │ }, │ │ +│ │ "_links": { │ │ +│ │ "download-http": { │ │ +│ │ "href": "http://.../update.zip" │ │ +│ │ } │ │ +│ │ } │ │ +│ │ }] │ │ +│ │ }] │ │ +│ │ } │ │ +│ │ } │ │ +│ └──────────────────────┬──────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Step B: 上报 DOWNLOAD (开始下载) │ │ +│ │ │ │ +│ │ POST /deploymentBase/{id}/feedback │ │ +│ │ {"status": { │ │ +│ │ "execution": "download", │ │ +│ │ "result": {"finished": "none"}, │ │ +│ │ "details": ["Starting download"] │ │ +│ │ }} │ │ +│ └──────────────────────┬──────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Step C: 下载 artifacts (逐个) │ │ +│ │ │ │ +│ │ 对每个 chunk.artifacts[]: │ │ +│ │ │ │ +│ │ ① 提取 download URL (优先级) │ │ +│ │ download (HTTPS) > download-http (HTTP) │ │ +│ │ 失败 → fallback: 遍历所有 HAL link 提取 smId │ │ +│ │ │ │ +│ │ ② 检查本地缓存 /tmp/hawkbit/{filename} │ │ +│ │ 存在 + SHA256 匹配 → 跳过下载 │ │ +│ │ 存在 + SHA256 不匹配 → 重新下载 │ │ +│ │ │ │ +│ │ ③ 检查 .tmp 断点文件 │ │ +│ │ 存在 → 加 Range: bytes={size}- 头续传 │ │ +│ │ │ │ +│ │ ④ GET /softwaremodules/{id}/artifacts/{filename} │ │ +│ │ 分块读取 64KB, 写入 .tmp │ │ +│ │ 打印下载进度百分比 │ │ +│ │ │ │ +│ │ ⑤ SHA256 校验 .tmp 文件 │ │ +│ │ 成功 → rename .tmp → 最终文件 │ │ +│ │ 失败 → 返回 None │ │ +│ │ │ │ +│ │ ⑥ 上报 PROCEEDING (每 artifact) │ │ +│ │ execution=proceeding, progress={cnt:1, of:1} │ │ +│ └──────────────────────┬──────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Step D: 上报 DOWNLOADED (全部下载完成) │ │ +│ │ │ │ +│ │ POST /deploymentBase/{id}/feedback │ │ +│ │ {"status": { │ │ +│ │ "execution": "downloaded", │ │ +│ │ "result": {"finished": "none"}, │ │ +│ │ "details": ["Downloaded 1 artifact(s)"] │ │ +│ │ }} │ │ +│ └──────────────────────┬──────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Step E: 上报 PROCEEDING (开始安装) │ │ +│ │ │ │ +│ │ POST /deploymentBase/{id}/feedback │ │ +│ │ {"status": { │ │ +│ │ "execution": "proceeding", │ │ +│ │ "result": {"finished": "none"}, │ │ +│ │ "details": ["Installing update …"] │ │ +│ │ }} │ │ +│ └──────────────────────┬──────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Step F: 执行安装 _install(files, handling) │ │ +│ │ │ │ +│ │ 默认行为: 检测到 *.zip 文件 → 返回 True │ │ +│ │ 生产: 覆盖此方法执行实际刷写 │ │ +│ └──────────────────────┬──────────────────────────────┘ │ +│ │ │ +│ ┌────┴────┐ │ +│ ▼ ▼ │ +│ 成功 失败 │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────────────────────┐ │ +│ │ Step G: 上报 CLOSED (最终) │ │ +│ │ │ │ +│ │ 成功: │ │ +│ │ execution=closed │ │ +│ │ result.finished=success │ │ +│ │ code=200 │ │ +│ │ │ │ +│ │ 失败: │ │ +│ │ execution=closed │ │ +│ │ result.finished=failure │ │ +│ │ code=500 │ │ +│ └───────────────────────────────┘ │ +│ │ +│ → 返回主循环 sleep → 下次轮询 │ +└────────────────────────────────────────────────────────────────┘ + +Hawkbit Action Status 映射: + execution=download → Status.DOWNLOAD + execution=downloaded → Status.DOWNLOADED + execution=proceeding → Status.RUNNING + execution=closed → result.success → Status.FINISHED + → result.failure → Status.ERROR + execution=canceled → Status.CANCELED + execution=rejected → Status.WARNING +``` + +--- + +## 6. 认证流程 + +``` +┌───────────────────────────────────────────────────────────┐ +│ 认证方式选择 │ +├───────────────────────────────────────────────────────────┤ +│ │ +│ ① GatewayToken (推荐,所有设备共享) │ +│ ────────────────────────────────────── │ +│ 服务端配置: │ +│ curl -u admin:admin -X PUT \ │ +│ .../system/configs/authentication.gatewaytoken.key \│ +│ -d '{"value": "my-shared-key"}' │ +│ │ +│ curl -u admin:admin -X PUT \ │ +│ .../system/configs/authentication.gatewaytoken.enabled \│ +│ -d '{"value": true}' │ +│ │ +│ 客户端: │ +│ 方式 A: 命令行参数 │ +│ --gateway-token my-shared-key │ +│ │ +│ 方式 B: 从文件读取 │ +│ echo "my-shared-key" > /etc/hawkbit/gateway.key │ +│ chmod 600 /etc/hawkbit/gateway.key │ +│ --gateway-token @/etc/hawkbit/gateway.key │ +│ │ +│ HTTP: Authorization: GatewayToken my-shared-key │ +│ │ +│ │ +│ ② TargetToken (每设备独立令牌) │ +│ ──────────────────────────────── │ +│ 服务端配置: │ +│ curl -u admin:admin -X PUT \ │ +│ .../system/configs/authentication.targettoken.enabled \│ +│ -d '{"value": true}' │ +│ │ +│ # 每个 target 的 securityToken 自动生成或手动设置 │ +│ curl -u admin:admin -X PUT \ │ +│ .../targets/{controllerId} \ │ +│ -d '{"securityToken": "device-unique-token"}' │ +│ │ +│ 客户端: │ +│ --target-token device-unique-token │ +│ 或 --target-token @/etc/hawkbit/target.token │ +│ │ +│ HTTP: Authorization: TargetToken device-unique-token │ +│ │ +│ │ +│ ③ SecurityHeader (反向代理模式, 脚本不支持) │ +│ ────────────────────────────────────────── │ +│ 适用于 nginx/Envoy 前置认证场景 │ +│ │ +│ │ +│ 优先级 (客户端): --gateway-token > --target-token │ +│ 无认证: 服务器返回 401 │ +│ 验证失败: 服务器返回 401 │ +└───────────────────────────────────────────────────────────┘ +``` + +--- + +## 7. 取消任务流程 + +``` +前置条件: 轮询返回 _links.cancelAction + +┌───────────────────────────────────────────────────┐ +│ 取消任务处理 │ +├───────────────────────────────────────────────────┤ +│ │ +│ ① GET /cancelAction/{actionId} │ +│ ← {"id": "11", │ +│ "cancelAction": {"stopId": "11"}} │ +│ │ +│ ② POST /cancelAction/{actionId}/feedback │ +│ Body: │ +│ {"status": { │ +│ "execution": "canceled", │ +│ "result": {"finished": "none"}, │ +│ "details": ["Cancel acknowledged"] │ +│ }} │ +│ │ +│ → 回到主循环 sleep → 下次轮询 │ +└───────────────────────────────────────────────────┘ +``` + +--- + +## 8. 确认任务流程 + +``` +前置条件: action 状态为 WAITING_FOR_CONFIRMATION + +┌───────────────────────────────────────────────────┐ +│ 确认任务处理 (headless) │ +├───────────────────────────────────────────────────┤ +│ │ +│ Headless 设备自动确认,不等待人工: │ +│ │ +│ POST /confirmationBase/{actionId}/feedback │ +│ Body: │ +│ {"confirmation": "confirmed", │ +│ "code": 200, │ +│ "details": ["Auto-confirmed by DDI client"]} │ +│ │ +│ → Action 状态从 WAITING_FOR_CONFIRMATION │ +│ 转为 RUNNING │ +│ → 下次轮询拿到 deploymentBase 链接 │ +│ → 进入正常 OTA 流程 │ +│ │ +│ 如果 autoConfirm.active=true: │ +│ 下次轮询时服务端直接返回 deploymentBase │ +│ 跳过确认步骤 │ +└───────────────────────────────────────────────────┘ +``` + +--- + +## 9. 断点续传流程 + +``` +┌───────────────────────────────────────────────────────────┐ +│ 断点续传 (Range 请求) │ +├───────────────────────────────────────────────────────────┤ +│ │ +│ 触发条件: --no-resume 未设置 (默认启用) │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ 下载 {filename} 之前 │ │ +│ │ │ │ +│ │ ① 检查 /tmp/hawkbit/{filename} │ │ +│ │ 存在 + SHA256 匹配 │ │ +│ │ → 跳过下载,直接返回本地路径 │ │ +│ │ │ │ +│ │ ② 检查 /tmp/hawkbit/{filename}.tmp │ │ +│ │ 存在 → os.path.getsize(tmp_path) = 1048576 │ │ +│ │ 添加头: Range: bytes=1048576- │ │ +│ │ 打开模式: "ab" (追加) │ │ +│ │ │ │ +│ │ ③ 文件不存在 │ │ +│ │ 无 Range 头 │ │ +│ │ 打开模式: "wb" (新建) │ │ +│ └──────────────────────┬──────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ GET /softwaremodules/{id}/artifacts/{filename} │ │ +│ │ Range: bytes=1048576- │ │ +│ │ ← 206 Partial Content │ │ +│ │ Content-Range: bytes 1048576-195410/195411 │ │ +│ │ (服务端从 offset 开始发送) │ │ +│ └──────────────────────┬──────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ 分块写入 + 进度报告 │ │ +│ │ │ │ +│ │ while chunk = resp.read(64KB): │ │ +│ │ fh.write(chunk) │ │ +│ │ downloaded += len(chunk) │ │ +│ │ pct = downloaded / total * 100 │ │ +│ │ │ │ +│ │ 中断 (Exception / 网络断): │ │ +│ │ .tmp 文件保留 │ │ +│ │ 下次启动时自动续传 │ │ +│ └──────────────────────┬──────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ SHA256 校验通过 │ │ +│ │ → os.rename(tmp_path, local_path) 原子重命名 │ │ +│ │ │ │ +│ │ SHA256 校验失败 │ │ +│ │ → 日志记录 mismatch │ │ +│ │ → 返回 None │ │ +│ │ → 调用方删除 .tmp 或手动恢复 │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ 禁用续传: │ +│ --no-resume → 每次都从头下载 │ +│ → 每次 .tmp 从 0 开始 │ +└───────────────────────────────────────────────────────────┘ +``` + +--- + +## 10. 错误处理流程 + +``` +┌───────────────────────────────────────────────────────────────┐ +│ 错误处理矩阵 │ +├───────────────┬───────────────────────┬───────────────────────┤ +│ 场景 │ 行为 │ 恢复 │ +├───────────────┼───────────────────────┼───────────────────────┤ +│ 轮询 HTTP 错误 │ 日志记录,退避 sleep │ 下次轮询自动重试 │ +│ (network/500) │ min(interval, 30s) │ │ +│ │ 守护模式继续 │ │ +│ │ --once: exit(1) │ │ +├───────────────┼───────────────────────┼───────────────────────┤ +│ 获取部署详情 │ send_feedback(closed, │ 下次轮询后 action │ +│ 失败 │ failure, "Cannot │ 可能还在 pending │ +│ │ fetch deployment") │ 状态,重新处理 │ +│ │ return False │ │ +├───────────────┼───────────────────────┼───────────────────────┤ +│ 下载失败 │ send_feedback(closed, │ .tmp 保留 │ +│ (HTTP error) │ failure, "Download │ 下次轮询自动续传 │ +│ │ failed for xxx") │ │ +│ │ return False │ │ +├───────────────┼───────────────────────┼───────────────────────┤ +│ SHA256 不匹配 │ 日志 error │ .tmp 保留,可手动删除 │ +│ │ "SHA256 MISMATCH" │ 下次重新下载 │ +│ │ return None │ │ +├───────────────┼───────────────────────┼───────────────────────┤ +│ 下载中断 │ 日志 error │ .tmp 保留 │ +│ (Exception) │ "Download interrupted" │ 下次自动续传 │ +│ │ return None │ │ +├───────────────┼───────────────────────┼───────────────────────┤ +│ 安装失败 │ send_feedback(closed, │ 下次轮询后 action │ +│ (_install 返 │ failure, code=500) │ 状态 → ERROR │ +│ 回 False) │ return False │ 管理端可见 │ +├───────────────┼───────────────────────┼───────────────────────┤ +│ SSL 证书错误 │ 捕获后当作普通 HTTP 错误 │ --no-ssl-verify │ +│ │ │ 跳过证书验证(不安全) │ +├───────────────┼───────────────────────┼───────────────────────┤ +│ 属性上报失败 │ 日志 error │ _attrs_reported=False │ +│ │ 不影响轮询继续 │ 下次轮询自动重试 │ +├───────────────┼───────────────────────┼───────────────────────┤ +│ Ctrl+C 中断 │ 日志 "Stopped" │ return True │ +│ │ sys.exit(0) │ │ +└───────────────┴───────────────────────┴───────────────────────┘ + +日志级别: + DEBUG - HTTP 请求/响应详情, 下载进度, 属性内容 (--verbose / -v) + INFO - 状态变化, action 处理, 睡眠倒计时 (默认) + WARNING- 非致命错误 (poll 失败, 缓存失效, 无认证) (默认) + ERROR - 致命错误 (下载失败, SHA256 不匹配, HTTP 错误) (默认) +``` + +--- + +## 11. 安装钩子定制 + +``` +┌───────────────────────────────────────────────────────────┐ +│ _install() 钩子定制 │ +├───────────────────────────────────────────────────────────┤ +│ │ +│ 默认行为: │ +│ 检测下载文件列表中是否有 *.zip 文件 → 返回 True │ +│ (适合测试,不做实际操作) │ +│ │ +│ 输入: │ +│ files: List[str] → ["/tmp/hawkbit/update.zip"] │ +│ handling: dict → {"download":"forced", │ +│ "update":"forced", │ +│ "maintenanceWindow":null} │ +│ │ +│ 返回: │ +│ True → send_feedback(closed, success) │ +│ False → send_feedback(closed, failure) │ +│ │ +├───────────────────────────────────────────────────────────┤ +│ │ +│ 方式 A: 子类继承 │ +│ ───────────────── │ +│ │ +│ # my_client.py │ +│ from ddi_client import HawkbitDDIClient │ +│ │ +│ class AndroidOTAClient(HawkbitDDIClient): │ +│ def _install(self, files, handling): │ +│ import subprocess │ +│ for fp in files: │ +│ if fp.endswith('.zip'): │ +│ # 写 recovery 命令 │ +│ subprocess.run([ │ +│ 'update_engine_client', │ +│ '--update', │ +│ f'file://{fp}' │ +│ ], check=True) │ +│ # 写版本号 │ +│ with open('/etc/hawkbit/current_version', │ +│ 'w') as f: │ +│ f.write('V2.0.0.0') │ +│ return True │ +│ │ +│ if __name__ == '__main__': │ +│ # 用自定义子类替换 CLI 入口 │ +│ client = AndroidOTAClient(...) │ +│ client.run() │ +│ │ +│ │ +│ 方式 B: Monkey-Patch (快速原型) │ +│ ───────────────────────────── │ +│ │ +│ client = HawkbitDDIClient(...) │ +│ original_install = client._install │ +│ def my_install(files, handling): │ +│ result = subprocess.run(['unzip', '-o', files[0], │ +│ '-d', '/data/ota']) │ +│ with open('/etc/hawkbit/current_version','w') as f: │ +│ f.write('V2.0.0.0') │ +│ return result.returncode == 0 │ +│ client._install = my_install │ +│ client.run() │ +│ │ +│ │ +│ 方式 C: 简单模式 (不需要 Python 继承) │ +│ ─────────────────────────────────── │ +│ │ +│ 创建安装脚本: /etc/hawkbit/install.sh │ +│ │ +│ #!/bin/sh │ +│ unzip -o "$1" -d /data/ota │ +│ echo "V2.0.0.0" > /etc/hawkbit/current_version │ +│ exit $? │ +│ │ +│ 修改 _install: │ +│ def _install(self, files, handling): │ +│ for fp in files: │ +│ if fp.endswith('.zip'): │ +│ import subprocess │ +│ result = subprocess.run( │ +│ ['/etc/hawkbit/install.sh', fp]) │ +│ return result.returncode == 0 │ +│ return True │ +└───────────────────────────────────────────────────────────┘ +``` + +--- + +## 12. 部署与运维 + +``` +┌───────────────────────────────────────────────────────────────┐ +│ 部署方案 │ +├───────────────────────────────────────────────────────────────┤ +│ │ +│ Android 设备部署: │ +│ ──────────────── │ +│ │ +│ # 1. 推送脚本 │ +│ adb push ddi-client.py /data/local/tmp/ │ +│ │ +│ # 2. 创建 token 文件 │ +│ adb shell "echo 'my-gateway-key' > /data/local/tmp/gateway.key" │ +│ adb shell "chmod 600 /data/local/tmp/gateway.key" │ +│ │ +│ # 3. 创建属性文件 (可选) │ +│ adb push device_attrs.json /data/local/tmp/ │ +│ adb shell "mkdir -p /etc/hawkbit" │ +│ adb shell "cp /data/local/tmp/device_attrs.json /etc/hawkbit/" │ +│ │ +│ # 4. 运行 │ +│ adb shell "python3 /data/local/tmp/ddi-client.py \ │ +│ -u https://hawkbit:9090 -t DEFAULT \ │ +│ --gateway-token @/data/local/tmp/gateway.key \ │ +│ -d /data/local/tmp/hawkbit" │ +│ │ +│ │ +│ Linux 设备部署 (systemd): │ +│ ───────────────────────── │ +│ │ +│ # /opt/hawkbit/ddi-client.py │ +│ # /etc/hawkbit/gateway.key │ +│ # /etc/hawkbit/device_attrs.json │ +│ │ +│ # /etc/systemd/system/hawkbit-ddi.service │ +│ [Unit] │ +│ Description=Hawkbit DDI Client │ +│ After=network-online.target │ +│ Wants=network-online.target │ +│ │ +│ [Service] │ +│ Type=simple │ +│ ExecStart=/usr/bin/python3 /opt/hawkbit/ddi-client.py \ │ +│ -u https://hawkbit:9090 -t DEFAULT \ │ +│ --gateway-token @/etc/hawkbit/gateway.key \ │ +│ -d /var/cache/hawkbit -i 30 │ +│ Restart=always │ +│ RestartSec=30 │ +│ User=root │ +│ │ +│ [Install] │ +│ WantedBy=multi-user.target │ +│ │ +│ systemctl enable --now hawkbit-ddi │ +│ │ +│ │ +│ 版本管理: │ +│ ──────── │ +│ │ +│ 安装成功后写入版本号: │ +│ echo "V2.0.0.0" > /etc/hawkbit/current_version │ +│ │ +│ 下次轮询时属性中自动包含 swVersion=V2.0.0.0 │ +│ 管理员可在 Hawkbit 中按版本筛选设备 │ +└───────────────────────────────────────────────────────────────┘ +``` + +--- + +## 附录 A: HTTP 请求/响应速查 + +| 操作 | 方法 | 路径 | Content-Type | +|------|------|------|-------------| +| 轮询 | GET | `/{tenant}/controller/v1/{controllerId}` | application/hal+json | +| 部署详情 | GET | `/{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}` | application/hal+json | +| 上报反馈 | POST | `/{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}/feedback` | application/json | +| 下载文件 | GET | `/{tenant}/controller/v1/{controllerId}/softwaremodules/{id}/artifacts/{file}` | application/octet-stream | +| 上报属性 | PUT | `/{tenant}/controller/v1/{controllerId}/configData` | application/json | +| 取消反馈 | POST | `/{tenant}/controller/v1/{controllerId}/cancelAction/{id}/feedback` | application/json | +| 确认反馈 | POST | `/{tenant}/controller/v1/{controllerId}/confirmationBase/{id}/feedback` | application/json | + +## 附录 B: 命令行参数完整列表 + +``` +-u, --base-url Hawkbit 服务端地址 (必填) +-t, --tenant 租户名称 (必填) +--gateway-token Gateway Token 或 @文件路径 +--target-token Target Token 或 @文件路径 +--controller-id 控制器ID (默认自动检测SN) +-d, --download-dir 下载目录 (/tmp/hawkbit) +-i, --polling-interval 轮询间隔秒数 (60) +--no-verify 跳过 SHA256 校验 +--no-resume 禁用断点续传 +--no-ssl-verify 跳过 TLS 证书验证 +--once 单次轮询后退出 +-v, --verbose 调试日志 +-h, --help 帮助 +``` + +## 附录 C: 设备属性文件格式 + +```json +# /etc/hawkbit/device_attrs.json +{ + "dept": "production", + "region": "east-1", + "owner": "team-a", + "rack": "A12", + "environment": "staging" +} +``` + +## 附录 D: 状态流转图 (Hawkbit 视角) + +``` + assignment + │ + ▼ + READY ──→ RETRIEVED ──→ DOWNLOAD ──→ DOWNLOADED ──→ RUNNING ──→ FINISHED + │ │ │ (成功) + │ │ │ + │ │ └──→ ERROR + │ │ (失败) + │ │ + └──────────┬───────────────┘ + │ + CANCELING ──→ CANCELED + │ + CANCEL_REJECTED + + WAITING_FOR_CONFIRMATION ──confirmed──→ RUNNING → ... + │ + └──denied──→ DENIED +``` diff --git a/hawkbit-compose/__pycache__/ddi-client.cpython-312.pyc b/hawkbit-compose/__pycache__/ddi-client.cpython-312.pyc new file mode 100644 index 000000000..58c71f3d0 Binary files /dev/null and b/hawkbit-compose/__pycache__/ddi-client.cpython-312.pyc differ diff --git a/hawkbit-compose/ddi-client.py b/hawkbit-compose/ddi-client.py new file mode 100755 index 000000000..814726a39 --- /dev/null +++ b/hawkbit-compose/ddi-client.py @@ -0,0 +1,844 @@ +#!/usr/bin/env python3 +""" +Hawkbit DDI Client — lightweight pure-Python OTA update agent. +================================================================= +No dependencies beyond Python 3 standard library. +Runs on Android (adb shell) and Linux hosts. + +DDI API version: v1 +Ref: https://github.com/eclipse-hawkbit/hawkbit + +Capabilities: + • Poll Hawkbit for deployment actions + • Resumable (Range-request) artifact download + • SHA256 file integrity verification + • Status progression: DOWNLOAD → DOWNLOADED → PROCEEDING → FINISHED / FAILURE + • Auto-detects device serial number as controllerId (Android ro.serialno, DMI, machine-id) + • Reports device attributes (osType, platform, hwRevision, swVersion, kernel…) to Hawkbit + • Authentication: GatewayToken or TargetToken (also from file: --gateway-token @/path/to/key) + +Quick start: + # Gateway token + ddi-client.py --base-url https://hawkbit:8443 --tenant DEFAULT --gateway-token mykey + + # Target token (per-device) + ddi-client.py --base-url http://10.0.0.1:8080 --tenant DEFAULT --target-token abc123 + + # One-shot poll + ddi-client.py --base-url http://localhost:8080 --tenant DEFAULT --gateway-token k --once +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import logging +import os +import subprocess +import ssl +import sys +import time +import urllib.error +import urllib.request +from typing import Any, Dict, List, Optional, Tuple + +# ── logging ───────────────────────────────────────────────────────────────── +logger = logging.getLogger("hawkbit-ddi") + + +# ═══════════════════════════════════════════════════════════════════════════════ +# HawkbitDDIClient +# ═══════════════════════════════════════════════════════════════════════════════ +class HawkbitDDIClient: + """Lightweight DDI (Direct Device Integration) client for Eclipse Hawkbit.""" + + # DDI execution statuses (mapped to Hawkbit action states) + EXEC_DOWNLOAD = "download" + EXEC_DOWNLOADED = "downloaded" + EXEC_PROCEEDING = "proceeding" + EXEC_CLOSED = "closed" + EXEC_CANCELED = "canceled" + EXEC_REJECTED = "rejected" + EXEC_SCHEDULED = "scheduled" + EXEC_RESUMED = "resumed" + + RESULT_SUCCESS = "success" + RESULT_FAILURE = "failure" + RESULT_NONE = "none" + + # ── constructor ────────────────────────────────────────────────────── + def __init__( + self, + base_url: str, + tenant: str, + controller_id: Optional[str] = None, + auth_header: Optional[str] = None, + download_dir: str = "/tmp/hawkbit", + verify_sha256: bool = True, + polling_interval: int = 60, + ssl_verify: bool = True, + resume_download: bool = True, + ) -> None: + self.base_url = base_url.rstrip("/") + self.tenant = tenant + self.controller_id = controller_id or self._detect_device_sn() + self.auth_header = auth_header + self.download_dir = download_dir + self.verify_sha256 = verify_sha256 + self.polling_interval = polling_interval + self.ssl_verify = ssl_verify + self.resume_download = resume_download + self._attrs_reported = False + + os.makedirs(self.download_dir, exist_ok=True) + + # ── device serial detection ─────────────────────────────────────────── + @staticmethod + def _detect_device_sn() -> str: + """Best-effort device serial number detection. + + Priority: Android ro.serialno → DMI product_serial → /etc/machine-id → hostname + """ + # 1. Android property + try: + out = subprocess.run( + ["getprop", "ro.serialno"], + capture_output=True, text=True, timeout=2, + ) + sn = out.stdout.strip() + if sn: + logger.info("Detected Android serial: %s", sn) + return sn + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + # 2. Linux DMI product serial + for p in ("/sys/class/dmi/id/product_serial", + "/sys/devices/virtual/dmi/id/product_serial"): + try: + with open(p) as fh: + sn = fh.read().strip() + if sn and sn not in ("0", "Not Specified", "None", ""): + logger.info("Detected DMI serial: %s", sn) + return sn + except (FileNotFoundError, PermissionError): + pass + + # 3. /etc/machine-id (systemd) + try: + with open("/etc/machine-id") as fh: + mid = fh.read().strip() + if mid: + logger.info("Using machine-id: %s", mid) + return mid + except (FileNotFoundError, PermissionError): + pass + + # 4. Fallback — hostname + import socket + hostname = socket.gethostname() + logger.warning("No serial found; falling back to hostname: %s", hostname) + return hostname + + # ── device attributes detection ───────────────────────────────────── + @staticmethod + def _detect_device_attrs() -> Dict[str, str]: + """Collect device metadata for Hawkbit target attributes. + + These attributes are used by Hawkbit target filters to auto-assign updates. + Returns a dict of key-value pairs. + """ + attrs: Dict[str, str] = {} + + # ── OS type ───────────────────────────────────────────────── + attrs["hostname"] = HawkbitDDIClient._safe_hostname() + + if os.path.exists("/system/build.prop"): + attrs["osType"] = "android" + else: + attrs["osType"] = "linux" + + # ── platform / architecture ───────────────────────────────── + try: + import platform + attrs["platform"] = platform.machine() + except Exception: + pass + + # ── kernel version ────────────────────────────────────────── + try: + attrs["kernel"] = os.uname().release + except Exception: + pass + + # ── Android: detailed attributes ──────────────────────────── + if attrs.get("osType") == "android": + for prop, attr_key in ( + ("ro.build.version.release", "androidVersion"), + ("ro.product.model", "productModel"), + ("ro.product.manufacturer", "manufacturer"), + ("ro.build.version.incremental", "buildNumber"), + ("ro.build.fingerprint", "buildFingerprint"), + ): + try: + out = subprocess.run( + ["getprop", prop], capture_output=True, text=True, timeout=2, + ) + val = out.stdout.strip() + if val: + attrs[attr_key] = val + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + # ── Linux: DMI hardware info ──────────────────────────────── + if attrs.get("osType") == "linux": + for path, attr_key in ( + ("/sys/class/dmi/id/product_name", "productName"), + ("/sys/class/dmi/id/product_version", "productVersion"), + ("/sys/class/dmi/id/product_sku", "productSku"), + ("/sys/class/dmi/id/sys_vendor", "manufacturer"), + ): + try: + with open(path) as fh: + val = fh.read().strip() + if val and val not in ("0", "Not Specified", "None", "", "System Product Name"): + attrs[attr_key] = val + except (FileNotFoundError, PermissionError): + pass + + # hwRevision: combine product name + version + if "productName" in attrs: + rev = attrs["productName"] + if "productVersion" in attrs: + rev += " " + attrs["productVersion"] + attrs["hwRevision"] = rev + + # ── current software version (if installed) ───────────────── + version_file = "/etc/hawkbit/current_version" + try: + with open(version_file) as fh: + v = fh.read().strip() + if v: + attrs["swVersion"] = v + except (FileNotFoundError, PermissionError): + pass + + # ── merge user-supplied attrs from file ───────────────────── + attrs_file = "/etc/hawkbit/device_attrs.json" + try: + with open(attrs_file) as fh: + extra = json.load(fh) + if isinstance(extra, dict): + attrs.update({k: str(v) for k, v in extra.items()}) + except (FileNotFoundError, PermissionError, json.JSONDecodeError): + pass + + return attrs + + @staticmethod + def _safe_hostname() -> str: + try: + import socket + return socket.gethostname() + except Exception: + return "unknown" + + # ── report device attributes to Hawkbit ────────────────────────────── + def report_attributes(self) -> bool: + """PUT /{tenant}/controller/v1/{controllerId}/configData + + Reports device metadata so Hawkbit can use target filters to assign updates. + Uses ``mode: merge`` — only updates the keys sent, preserves existing attributes. + """ + attrs = self._detect_device_attrs() + url = self._ddi_url(f"{self.controller_id}/configData") + payload = {"mode": "merge", "data": attrs} + logger.info("Reporting %d device attributes → %s", len(attrs), url) + logger.debug("Attributes: %s", json.dumps(attrs)) + resp = self._http(url, method="PUT", data=payload) + return resp is not None + def _ddi_url(self, path: str) -> str: + """Build a DDI v1 URL: //controller/v1/""" + return f"{self.base_url}/{self.tenant}/controller/v1/{path}" + + # ── low-level HTTP ──────────────────────────────────────────────────── + def _http( + self, + url: str, + method: str = "GET", + data: Any = None, + headers: Optional[Dict[str, str]] = None, + stream: bool = False, + ): + """Make an HTTP request. Returns decoded JSON, or raw file handle when + ``stream=True``. Returns ``None`` on HTTP errors.""" + req_headers: Dict[str, str] = {"Accept": "application/hal+json, application/json"} + if self.auth_header: + req_headers["Authorization"] = self.auth_header + if data is not None: + req_headers["Content-Type"] = "application/json" + if headers: + req_headers.update(headers) + + body = None if data is None else json.dumps(data).encode("utf-8") + + ctx = None + if not self.ssl_verify: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + req = urllib.request.Request(url, data=body, headers=req_headers, method=method) + + try: + resp = urllib.request.urlopen(req, context=ctx) + except urllib.error.HTTPError as exc: + err_body = exc.read().decode("utf-8", errors="replace")[:500] + logger.error("HTTP %d %s %s → %s", exc.code, method, url, err_body) + return None + + if stream: + return resp + + raw = resp.read() + if not raw: + return {} + return json.loads(raw.decode("utf-8")) + + # ── helper: extract best download link from artifact HAL links ───────── + @staticmethod + def _pick_download_url(artifact: dict) -> Optional[str]: + """Pick the preferred download URL from the artifact's _links. + + Priority: download (HTTPS) → download-http (HTTP) → construct from filename + """ + links = artifact.get("_links", {}) + if not isinstance(links, dict): + return None + + # HAL links are keyed by rel; values can be a single object or a list. + for rel in ("download", "download-http"): + entry = links.get(rel) + if isinstance(entry, list): + for item in entry: + if isinstance(item, dict) and "href" in item: + return item["href"] + elif isinstance(entry, dict) and "href" in entry: + return entry["href"] + return None + + # ═══════════════════════════════════════════════════════════════════════ + # Public API + # ═══════════════════════════════════════════════════════════════════════ + + # ── poll ────────────────────────────────────────────────────────────── + def poll(self) -> Optional[dict]: + """GET /{tenant}/controller/v1/{controllerId} + + Returns parsed controller base or None on failure. + """ + url = self._ddi_url(self.controller_id) + logger.debug("Polling GET %s", url) + body = self._http(url) + if body is None: + return None + return self._parse_poll(body) + + def _parse_poll(self, body: dict) -> dict: + result: Dict[str, Any] = { + "config": body.get("config", {}), + "links": {}, + } + + links = body.get("_links", {}) + if isinstance(links, dict): + for rel in ("deploymentBase", "cancelAction", + "confirmationBase", "installedBase", "configData"): + entry = links.get(rel) + href = None + if isinstance(entry, dict): + href = entry.get("href") + elif isinstance(entry, list) and entry: + href = entry[0].get("href") if isinstance(entry[0], dict) else None + if href: + result["links"][rel] = href + + # Adopt server-suggested polling interval + sleep_str = body.get("config", {}).get("polling", {}).get("sleep", "") + if sleep_str: + try: + h, m, s = (int(x) for x in sleep_str.split(":")) + seconds = h * 3600 + m * 60 + s + if seconds > 0: + self.polling_interval = seconds + except ValueError: + pass + + return result + + # ── get deployment base ─────────────────────────────────────────────── + def get_deployment(self, deployment_url: str) -> Optional[dict]: + """GET /{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId} + + Returns structured deployment info or None. + """ + logger.debug("Fetching deployment: %s", deployment_url) + body = self._http(deployment_url) + if body is None: + return None + return self._parse_deployment(body) + + def _parse_deployment(self, body: dict) -> dict: + info = body.get("deployment", {}) + + chunks = [] + for ch in info.get("chunks", []): + artifacts = [] + for art in ch.get("artifacts", []): + artifacts.append({ + "filename": art["filename"], + "size": art.get("size", 0), + "hashes": art.get("hashes", {}), + "_links": art.get("_links", {}), + }) + chunks.append({ + "part": ch.get("part", ""), + "version": ch.get("version", ""), + "name": ch.get("name", ""), + "artifacts": artifacts, + }) + + return { + "action_id": body.get("id"), + "chunks": chunks, + "handling": { + "download": info.get("download", "attempt"), + "update": info.get("update", "attempt"), + "maintenanceWindow": info.get("maintenanceWindow"), + }, + } + + # ── download artifact ───────────────────────────────────────────────── + def download_artifact(self, artifact: dict, action_id: str = "") -> Optional[str]: + """Download one artifact with Range-request resume and SHA256 verification. + + Returns the local file path on success, or None on failure. + """ + filename = artifact["filename"] + expected_sha256 = artifact.get("hashes", {}).get("sha256") + expected_size = artifact.get("size", 0) + + # Resolve the download URL + download_url = self._pick_download_url(artifact) + if not download_url: + # Fallback — construct from DDI path + # Try to extract smId from any artifact HAL link + logger.warning("No download link in artifact — using fallback URL") + import re + sm_id = "0" + links = artifact.get("_links", {}) + for entry in links.values(): + href = None + if isinstance(entry, dict) and "href" in entry: + href = entry["href"] + elif isinstance(entry, list): + for item in entry: + if isinstance(item, dict) and "href" in item: + href = item["href"] + break + if href: + m = re.search(r"/softwaremodules/(\d+)/", href) + if m: + sm_id = m.group(1) + break + download_url = self._ddi_url( + f"{self.controller_id}/softwaremodules/{sm_id}/artifacts/{filename}" + ) + + local_path = os.path.join(self.download_dir, filename) + tmp_path = local_path + ".tmp" + + # Already downloaded and verified? + if os.path.exists(local_path): + if self._sha256_check(local_path, expected_sha256): + logger.info("Already cached & verified: %s", local_path) + return local_path + logger.warning("Cached file fails SHA256 — re-downloading") + + # ── resume ──────────────────────────────────────────────────── + downloaded = 0 + if self.resume_download and os.path.exists(tmp_path): + downloaded = os.path.getsize(tmp_path) + logger.info("Resuming from byte %d", downloaded) + + req_headers: Dict[str, str] = {} + if downloaded > 0: + req_headers["Range"] = f"bytes={downloaded}-" + + logger.info("Downloading %s (%s bytes) → %s", + filename, expected_size or "?", tmp_path) + + resp = self._http(download_url, headers=req_headers, stream=True) + if resp is None: + logger.error("Download request failed") + return None + + # Determine total size (from Content-Range header) + total = expected_size + cr = resp.headers.get("Content-Range", "") + if cr: + try: + total = int(cr.rsplit("/", 1)[-1]) + except ValueError: + pass + + mode = "ab" if downloaded > 0 else "wb" + try: + with open(tmp_path, mode) as fh: + while True: + chunk = resp.read(64 * 1024) + if not chunk: + break + fh.write(chunk) + downloaded += len(chunk) + if total: + pct = downloaded / total * 100 + logger.debug(" %6.1f %% (%d / %d)", pct, downloaded, total) + except Exception as exc: + logger.error("Download interrupted: %s (tmp kept for resume)", exc) + return None + + logger.info("Download finished — %d bytes", downloaded) + + # SHA256 + if self.verify_sha256 and expected_sha256: + if not self._sha256_check(tmp_path, expected_sha256): + logger.error("SHA256 MISMATCH for %s", filename) + return None + + # Atomic rename + if os.path.exists(local_path): + os.remove(local_path) + os.rename(tmp_path, local_path) + return local_path + + @staticmethod + def _sha256_check(filepath: str, expected: Optional[str]) -> bool: + if not expected: + return True # nothing to compare against + h = hashlib.sha256() + try: + with open(filepath, "rb") as fh: + while True: + chunk = fh.read(64 * 1024) + if not chunk: + break + h.update(chunk) + except OSError: + return False + actual = h.hexdigest() + ok = actual.lower() == expected.lower() + if ok: + logger.info("SHA256 verified %s", actual) + else: + logger.error("SHA256 mismatch!\n expected: %s\n actual: %s", expected, actual) + return ok + + # ── send feedback ───────────────────────────────────────────────────── + def send_feedback( + self, + deployment_url: str, + execution: str, + result: str = RESULT_NONE, + progress: Optional[Tuple[int, int]] = None, + details: Optional[List[str]] = None, + code: Optional[int] = None, + ) -> Optional[dict]: + """POST …/feedback — report execution status to the server. + + Args: + deployment_url: full URL to the deployment base / cancel action / confirmation base. + execution: one of the EXEC_* constants. + result: ``success``, ``failure``, or ``none``. + progress: ``(cnt, of)`` e.g. ``(2, 5)``. + details: human-readable messages (shows in Hawkbit UI). + code: optional numeric status code. + """ + # strip query params — they belong to GET, not POST /feedback + base = deployment_url.split("?")[0] + url = base + "/feedback" + + result_obj: Dict[str, Any] = {"finished": result} + if progress: + result_obj["progress"] = {"cnt": progress[0], "of": progress[1]} + + status: Dict[str, Any] = { + "execution": execution, + "result": result_obj, + "details": details or [], + } + if code is not None: + status["code"] = code + + payload = { + "timestamp": int(time.time() * 1000), + "status": status, + } + + logger.info("Feedback %-12s / %-7s → %s", execution, result, url) + return self._http(url, method="POST", data=payload) + + # ── process deployment ──────────────────────────────────────────────── + def process_deployment(self, deployment_url: str) -> bool: + """Run the full deployment lifecycle: fetch metadata → download → verify → report.""" + + dep = self.get_deployment(deployment_url) + if not dep: + logger.error("Cannot fetch deployment details") + self.send_feedback(deployment_url, self.EXEC_CLOSED, self.RESULT_FAILURE, + details=["Failed to fetch deployment base"]) + return False + + action_id = dep["action_id"] + handling = dep["handling"] + logger.info("══ Action %s ══ download=%s update=%s", + action_id, handling["download"], handling["update"]) + + # 1. DOWNLOAD phase — report start + self.send_feedback(deployment_url, self.EXEC_DOWNLOAD, self.RESULT_NONE, + details=["Starting download"]) + + # 2. Fetch every artifact + downloaded: List[str] = [] + for chunk in dep["chunks"]: + logger.info("Chunk: %s v%s (%d artifacts)", + chunk["name"], chunk["version"], len(chunk["artifacts"])) + for idx, art in enumerate(chunk["artifacts"], 1): + # Per-artifact progress + self.send_feedback( + deployment_url, self.EXEC_PROCEEDING, self.RESULT_NONE, + progress=(idx, len(chunk["artifacts"])), + details=[f"Downloading {art['filename']}"], + ) + local = self.download_artifact(art, action_id) + if local is None: + self.send_feedback( + deployment_url, self.EXEC_CLOSED, self.RESULT_FAILURE, + details=[f"Download failed for {art['filename']}"], + ) + return False + downloaded.append(local) + + # 3. DOWNLOADED — all artifacts on disk, hashes ok + self.send_feedback(deployment_url, self.EXEC_DOWNLOADED, self.RESULT_NONE, + details=[f"Downloaded {len(downloaded)} artifact(s)"]) + + # 4. PROCEEDING — install + self.send_feedback(deployment_url, self.EXEC_PROCEEDING, self.RESULT_NONE, + details=["Installing update …"]) + + success = self._install(downloaded, handling) + + # 5. CLOSED — success or failure + if success: + self.send_feedback(deployment_url, self.EXEC_CLOSED, self.RESULT_SUCCESS, + details=["Installation complete"], code=200) + else: + self.send_feedback(deployment_url, self.EXEC_CLOSED, self.RESULT_FAILURE, + details=["Installation failed"], code=500) + return success + + # ── install hook — override this for your device ────────────────────── + def _install(self, files: List[str], handling: dict) -> bool: + """Apply the downloaded update. **Override in subclass or monkey-patch.**""" + logger.info("Install hook — files: %s", files) + # Default: look for update.zip and report success. + for fp in files: + if os.path.basename(fp).lower().endswith(".zip"): + # Example: subprocess.run(["unzip", "-o", fp, "-d", "/data/ota"], check=True) + logger.info("Would install: %s", fp) + return True + return bool(files) + + # ── cancel action ───────────────────────────────────────────────────── + def process_cancel(self, cancel_url: str) -> None: + """Acknowledge a cancel action.""" + self.send_feedback(cancel_url + "/feedback", self.EXEC_CANCELED, + self.RESULT_NONE, details=["Cancel acknowledged"]) + + # ── confirmation (auto-confirm for headless devices) ────────────────── + def process_confirmation(self, confirmation_url: str) -> None: + """Auto-confirm a WAITING_FOR_CONFIRMATION action.""" + logger.info("Auto-confirming: %s", confirmation_url) + body = { + "confirmation": "confirmed", + "code": 200, + "details": ["Auto-confirmed by DDI client"], + } + self._http(confirmation_url + "/feedback", method="POST", data=body) + + # ── main loop ───────────────────────────────────────────────────────── + def run(self, *, daemon: bool = True, max_cycles: Optional[int] = None) -> bool: + """Run the polling loop. + + Args: + daemon: If False, poll once and return. + max_cycles: Maximum number of poll cycles (None = forever). + """ + logger.info("╔══════════════════════════════════════════════════════════╗") + logger.info("║ Hawkbit DDI Client v1.0 ║") + logger.info("╠══════════════════════════════════════════════════════════╣") + logger.info("║ Server: %s", self.base_url) + logger.info("║ Tenant: %s", self.tenant) + logger.info("║ Device: %s", self.controller_id) + logger.info("║ Auth: %s", + self.auth_header.split(" ", 1)[0] if self.auth_header else "none") + logger.info("╚══════════════════════════════════════════════════════════╝") + + cycle = 0 + while max_cycles is None or cycle < max_cycles: + cycle += 1 + logger.info("── Poll cycle %d ──", cycle) + + poll_result = self.poll() + if poll_result is None: + logger.warning("Poll failed; retrying in %ds", min(self.polling_interval, 30)) + if not daemon: + return False + time.sleep(min(self.polling_interval, 30)) + continue + + # ── report device attributes after first successful poll ── + # Target is auto-created by GET /controller/v1/{SN} (findOrRegister…); + # configData requires an existing target → poll first, then report. + if not self._attrs_reported: + self.report_attributes() + self._attrs_reported = True + + links = poll_result.get("links", {}) + + if "deploymentBase" in links: + self.process_deployment(links["deploymentBase"]) + + elif "cancelAction" in links: + self.process_cancel(links["cancelAction"]) + + elif "confirmationBase" in links: + self.process_confirmation(links["confirmationBase"]) + + if "deploymentBase" not in links and "cancelAction" not in links and "confirmationBase" not in links: + logger.debug("No pending action.") + + if not daemon: + return True + + logger.info("Sleeping %ds …", self.polling_interval) + time.sleep(self.polling_interval) + + return True + + +# ═══════════════════════════════════════════════════════════════════════════════ +# CLI entry point +# ═══════════════════════════════════════════════════════════════════════════════ +def main(argv: Optional[List[str]] = None) -> None: + ap = argparse.ArgumentParser( + description="Hawkbit DDI Client — lightweight OTA agent (pure Python)", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Gateway token, poll every 30s + %(prog)s -u https://hawkbit.example.com -t DEFAULT --gateway-token s3cret -i 30 + + # Target token, one-shot + %(prog)s -u http://10.0.0.1:8080 -t DEFAULT --target-token tok --once + + # Custom controller-id, insecure SSL, custom download dir + %(prog)s -u https://hawkbit:8443 -t prod --gateway-token k \\ + --controller-id edge-042 -d /data/ota --no-ssl-verify -v +""", + ) + + # ── required ────────────────────────────────────────────────────── + ap.add_argument("-u", "--base-url", required=True, + help="Hawkbit base URL, e.g. https://hawkbit.example.com") + ap.add_argument("-t", "--tenant", required=True, + help="Tenant name, e.g. DEFAULT") + + # ── authentication (mutually-exclusive group would be nice but accepts either) ─ + ap.add_argument("--gateway-token", metavar="TOKEN", + help="Gateway security token → Authorization: GatewayToken TOKEN") + ap.add_argument("--target-token", metavar="TOKEN", + help="Target security token → Authorization: TargetToken TOKEN") + + # ── optional ────────────────────────────────────────────────────── + ap.add_argument("--controller-id", + help="Controller ID (default: auto-detect from device)") + ap.add_argument("-d", "--download-dir", default="/tmp/hawkbit", + help="Directory for downloaded artifacts [default: /tmp/hawkbit]") + ap.add_argument("-i", "--polling-interval", type=int, default=60, + help="Default polling interval in seconds (overridden by server) [default: 60]") + ap.add_argument("--no-verify", action="store_true", + help="Skip SHA256 verification") + ap.add_argument("--no-resume", action="store_true", + help="Disable resumable download") + ap.add_argument("--no-ssl-verify", action="store_true", + help="Disable TLS certificate verification (INSECURE)") + ap.add_argument("--once", action="store_true", + help="Poll exactly once then exit") + ap.add_argument("-v", "--verbose", action="store_true", + help="Debug-level logging") + + args = ap.parse_args(argv) + + # ── logging ─────────────────────────────────────────────────────── + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s [%(levelname)-5s] %(name)s: %(message)s", + datefmt="%H:%M:%S", + ) + + # ── auth header ─────────────────────────────────────────────────── + auth = None + if args.gateway_token: + token = args.gateway_token + if token.startswith("@"): + with open(token[1:]) as f: + token = f.read().strip() + logger.info("Read gateway token from file: %s", args.gateway_token) + auth = f"GatewayToken {token}" + elif args.target_token: + token = args.target_token + if token.startswith("@"): + with open(token[1:]) as f: + token = f.read().strip() + logger.info("Read target token from file: %s", args.target_token) + auth = f"TargetToken {token}" + + if not auth: + logger.warning("No authentication token provided — server may reject requests") + + # ── run ─────────────────────────────────────────────────────────── + client = HawkbitDDIClient( + base_url=args.base_url, + tenant=args.tenant, + controller_id=args.controller_id, + auth_header=auth, + download_dir=args.download_dir, + verify_sha256=not args.no_verify, + polling_interval=args.polling_interval, + ssl_verify=not args.no_ssl_verify, + resume_download=not args.no_resume, + ) + + try: + ok = client.run(daemon=not args.once) + sys.exit(0 if ok else 1) + except KeyboardInterrupt: + logger.info("Stopped (Ctrl+C)") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/hawkbit-compose/docker-compose.yml b/hawkbit-compose/docker-compose.yml new file mode 100644 index 000000000..d3f13d922 --- /dev/null +++ b/hawkbit-compose/docker-compose.yml @@ -0,0 +1,31 @@ +services: + hawkbit-backend: + image: hawkbit/hawkbit-update-server:local + container_name: hawkbit-backend + ports: + - "6060:8080" + restart: unless-stopped + command: > + --hawkbit.dmf.rabbitmq.enabled=false + --hawkbit.server.ddi.security.authentication.anonymous.enabled=true + environment: + JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1024m" + networks: + hawk_default_net: + + hawkbit-ui: + image: hawkbit/hawkbit-ui:local + container_name: hawkbit-ui + ports: + - "6081:8088" + restart: unless-stopped + command: -jar app.jar --hawkbit.server.url=http://hawkbit-backend:8080 + environment: + JAVA_TOOL_OPTIONS: "-Xms256m -Xmx512m" + networks: + hawk_default_net: + +# 显式定义共享网桥,两个服务强制加入同一网络 +networks: + hawk_default_net: + driver: bridge diff --git a/hawkbit-compose/docker-compose.yml.bak b/hawkbit-compose/docker-compose.yml.bak new file mode 100644 index 000000000..4c52888da --- /dev/null +++ b/hawkbit-compose/docker-compose.yml.bak @@ -0,0 +1,25 @@ +services: + hawkbit-backend: + image: hawkbit/hawkbit-update-server:local + container_name: hawkbit-backend + ports: + - "6060:8080" + restart: unless-stopped + command: > + --hawkbit.dmf.rabbitmq.enabled=false + --hawkbit.server.ddi.security.authentication.anonymous.enabled=true + environment: + JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1024m" + + hawkbit-ui: + image: hawkbit/hawkbit-ui:local + container_name: hawkbit-ui + ports: + - "6081:8088" + restart: unless-stopped + # 修复shell语法,单条完整命令,无换行拆分bug + command: /bin/sh -c "sleep 15; java $JAVA_TOOL_OPTIONS -jar app.jar --hawkbit.server.url=http://hawkbit-backend:8080" + environment: + JAVA_TOOL_OPTIONS: "-Xms256m -Xmx512m" + depends_on: + - hawkbit-backend