提交api调用相关信息
This commit is contained in:
BIN
hawkbit-compose/.RUNBOOK.md.swp
Normal file
BIN
hawkbit-compose/.RUNBOOK.md.swp
Normal file
Binary file not shown.
746
hawkbit-compose/CURL-API.md
Normal file
746
hawkbit-compose/CURL-API.md
Normal file
@@ -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`=尝<><E5B09D><EFBFBD>下载 / `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 |
|
||||
214
hawkbit-compose/README.md
Normal file
214
hawkbit-compose/README.md
Normal file
@@ -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://<hawkbit>:9090/rest/v1/targets/<controllerId>" \
|
||||
-d '{"securityToken": "my-device-token"}'
|
||||
```
|
||||
|
||||
启用租户级 TargetToken 认证:
|
||||
|
||||
```bash
|
||||
curl -u admin:admin -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://<hawkbit>: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://<hawkbit>: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://<hawkbit>: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 # 脚本本体(单文件,可直接部署)
|
||||
```
|
||||
|
||||
无其他依赖文件。
|
||||
902
hawkbit-compose/RUNBOOK.md
Normal file
902
hawkbit-compose/RUNBOOK.md
Normal file
@@ -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: <SN> ║ │
|
||||
│ ▼ ║ 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
|
||||
```
|
||||
BIN
hawkbit-compose/__pycache__/ddi-client.cpython-312.pyc
Normal file
BIN
hawkbit-compose/__pycache__/ddi-client.cpython-312.pyc
Normal file
Binary file not shown.
844
hawkbit-compose/ddi-client.py
Executable file
844
hawkbit-compose/ddi-client.py
Executable file
@@ -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: <base>/<tenant>/controller/v1/<path>"""
|
||||
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()
|
||||
31
hawkbit-compose/docker-compose.yml
Normal file
31
hawkbit-compose/docker-compose.yml
Normal file
@@ -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
|
||||
25
hawkbit-compose/docker-compose.yml.bak
Normal file
25
hawkbit-compose/docker-compose.yml.bak
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user